diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5cfee8d55..75ebd1d7c 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (272) +## In this repository (276) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -280,6 +280,10 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | +| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index fbabdf6d4..514b5c2f0 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e2ea97b4c68e13c22ef7f6f39f9a29d21af0e562005e2fdc9e7dfdf29effe0bb" +sha256 = "467f1e5b55c0f036b35bb72b8a23c7cd0a8bd96ae188d742393b7dc0635166df" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ac0a78f36b91c005b9543e6028b78bb44949fea1bf7991bf0791d407696cd832" +sha256 = "4a9d4428c3f67e256aa2f9e9d2d057c0e40f60a4446a0b384f3055894a9a9213" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c52b9a2aa403937e70859fe60b51719c97475c24927322d7534f5da0bdb5f420" +sha256 = "f22298173d1e174feb83f6fb20e7079bb9091df86f396c69648b974f429be158" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dcad418fa6399955adf2a8398aec307c2c73b13a37ca78ffe142e00491ef586" +sha256 = "e320d5f50795dc1c46d589d72c7a27facb6164938cf9d75151e904b7450b142d" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "130f0256d4ba09bc9cb6a87fc973bad96d6b155703b713341f0afead9ad6b4b3" +sha256 = "0dd7e0e0b0843b519cebc5504ce7006e4c9cb1eef02b270ed52d008972ee7ff3" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d1093647d2af3d51f77a7b53f05ef1f45442128d40e696022624fbbece5bd0f0" +sha256 = "dda5b3aea60d718eb2fe28e02a76728a3c895487e03f9319c2bcdef7ea2012db" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "135d00bc60ef62f45ebee7c7fd83bc7433fd4054a04fb3901b2d2e8bdd2871c7" +sha256 = "3351f6abd5e2796ad5e70b00604c9a7093387243ad1951713a717d06bffe419d" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0cb38f42fe45831495699f81837fb0b2d5bb16b1b69153359c4376e9bc1e6e04" +sha256 = "2595a893a3cb84b6f0344b9e2144d9e956e80f778eb7d045fa2ad503e3cfb267" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ce97821a0fd6bf758e88e654f70985ed1e94c81e36f4e31ee3fc2f05ca39d99f" +sha256 = "ec2d2afca09390c809e805a52108f5e8c94bf61feef7d0ba2901723db747323d" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "45567329bdb7884b0b54e5c5161e6e73452d85200945f1ba3661eab1fa35da06" +sha256 = "683f515a21dbc4b22b312c63e39b4d2a572a1f1cf733f02127f1ee62dbb7a05e" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "421ef329305624ebfc661a8d9cd82131e79ec279b8be47d4baec29a226e494d9" +sha256 = "e31d3969393994c33b90cd5fa82e80584955e4e0676df37136e345db476623ed" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "97ed35e8959777a5f15b087136e53385430241e8522c7dc72a383c72dc0a27fd" +sha256 = "fee17b5090aba63d3129772c271d9802444918fe3a905734b41e006e320e9234" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bc012e0dd9ad0d212a32b71bfc75a42ffcd058484aab28d173078384c9da480f" +sha256 = "ef26690aeec1c4b6b648ac50bc2433fec681524ab98e6270cabd3d76be6d3e14" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bd611d470f40a01b4442119f7189dabbe58b4904dbce398115399f02b2ecddaa" +sha256 = "2452a0f56f455ad71cae439a2ecf6da0ae5e2aaa90790de4d16e222393030283" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1132e904bc1a2bcc8239987aa74b2fa75ca17d8eb20b5458eec7e2c075c0445a" +sha256 = "bbf8c454edc80ddeaecce63e56f5bceba335ae6cce8e4456b14742cd3a820bfe" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "11dd8250c667ca49c40b827632b2d83cad93d07a9d5a5f27b5b9d657293efb6b" +sha256 = "64d3c1c532ecb94b44b2c894acdaf171a130cbaa8e173b099f9b9b249d2134f4" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6aa0788d784532fb67a0b589cecc27f727e6178221ee892c3de7fe8f75790607" +sha256 = "9e87a8f108b49213c7bb0f077a2e2f1bae449e1459a908d8202beba280d8055c" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ed84a08b83035bc6f4d328b916692d40140c2748d383741d04fba6e013a76bb3" +sha256 = "921e8e906bdc42675fe8922fc00835a255e019ed61688a01095367f6e881f187" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1a5b3e9ad3f524e04bdb60e11f4a1424ca749a5ed4840e22abeaa97d24d235e8" +sha256 = "59070be5688d7d183a7354f08f9604e8f962dcc5e223dd91d1cd940997c47399" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "136f1e68fc4caa5e6e8fe395551bffdc9ace93bb00e2da5ba59318846494420d" +sha256 = "f48accb38d4c824cb3d2f71fcffe0d8edb0a9a5f7412b9c479da6fb2479e45d2" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "052ecb803601ebc547934136c936fb5527d6dc97e49cf8cfbde0aa2b1a6cb8c7" +sha256 = "42212a46ddca212bb94c15062be3b444b33181ea68cdd9880fc5191f56d986a5" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "299c274d071478c49430d8dbc2a1979085fa4f8735324697e8ac0db3d3adce7c" +sha256 = "fcc38245d923c4ddefb5560fe7fdfe5bc6c1b9c49d4fcb25f2873e63dff4f806" [[asset]] @@ -3376,3 +3376,47 @@ licence_file = "vendor/vello_common/LICENSE-MIT" kind = "image" usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file" sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" + +[[asset]] +path = "assets/icons/superscript.svg" +title = "Lucide icon \"superscript\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0" + +[[asset]] +path = "assets/icons/subscript.svg" +title = "Lucide icon \"subscript\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4" + +[[asset]] +path = "assets/icons/pilcrow.svg" +title = "Lucide icon \"pilcrow\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8" + +[[asset]] +path = "assets/icons/move-horizontal.svg" +title = "Lucide icon \"move-horizontal\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" diff --git a/assets/icons/move-horizontal.svg b/assets/icons/move-horizontal.svg new file mode 100644 index 000000000..7be9e3af6 --- /dev/null +++ b/assets/icons/move-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/pilcrow.svg b/assets/icons/pilcrow.svg new file mode 100644 index 000000000..1a1b38b15 --- /dev/null +++ b/assets/icons/pilcrow.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/subscript.svg b/assets/icons/subscript.svg new file mode 100644 index 000000000..a37e9c42d --- /dev/null +++ b/assets/icons/subscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/superscript.svg b/assets/icons/superscript.svg new file mode 100644 index 000000000..c7978fb09 --- /dev/null +++ b/assets/icons/superscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5f5ef32f7..87f4c7756 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -4,7 +4,7 @@ //! Delete to remove it. Each change is one undoable engine edit. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; -use pdfcraft_engine::{Added, AddedContent, AddedText, Edit, FontFamily, TextAlign}; +use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; @@ -605,51 +605,6 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { } } -/// Format text (shown in the Edit panel while a text item is selected, or for new text). -/// Returns the changed style; for a selected item the caller turns it into an update. -pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> Option { - let mut s = style.clone(); - widgets::section_title(ui, tl!("Format text")); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("font-family").selected_text(s.family.label()).width(110.0).show_ui(ui, |ui| { - for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { - ui.selectable_value(&mut s.family, f, f.label()); - } - }); - // A list rather than a drag value: every change is an undo step. - egui::ComboBox::from_id_salt("font-size").selected_text(format!("{} pt", s.size)).width(70.0).show_ui(ui, |ui| { - for size in [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { - ui.selectable_value(&mut s.size, size, format!("{size} pt")); - } - }); - }); - ui.horizontal(|ui| { - if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")).clicked() { - s.bold = !s.bold; - } - if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")).clicked() { - s.italic = !s.italic; - } - ui.separator(); - for (a, icon, tip) in [ - (TextAlign::Left, "align-left", tl!("Align left")), - (TextAlign::Center, "align-center", tl!("Centre")), - (TextAlign::Right, "align-right", tl!("Align right")), - (TextAlign::Justify, "align-justify", tl!("Justify")), - ] { - if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() { - s.align = a; - } - } - }); - let c = s.color; - if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) { - s.color = picked; - } - ui.label(egui::RichText::new(tl!("Thai and Arabic use regular fallback fonts when available.")).small().color(t.text_faint)); - (s != *style).then_some(s) -} - /// How to use the tools, under the Add content list. pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) { ui.label( diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 66ad98ffe..d272480d2 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -162,6 +162,10 @@ pub struct LineEditor { /// The Format text panel's values, and what the paragraph had (to send only changes). pub look: pdfcraft_engine::AddedText, look0: pdfcraft_engine::AddedText, + /// A standard family was picked from the font list. The family guessed from a PDF font name + /// (Calibri reads as Helvetica) can be the one picked, so comparing `look` with `look0` alone + /// would miss the choice. + family_chosen: bool, /// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and /// horizontal scale (%), and what they were. pub extras: Extras, @@ -182,36 +186,42 @@ impl Default for Extras { } } -/// Underline, line spacing, character spacing and horizontal scale for the paragraph being -/// edited (under Format text). Returns `true` when something changed. -pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool { - let before = *e; - ui.horizontal(|ui| { - if crate::icons::button(ui, "underline", 26.0, e.underline, tl!("Underline")).clicked() { - e.underline = !e.underline; - } - let zero = tl!("Line spacing").to_string(); - let label = |v: f64| if v == 0.0 { zero.clone() } else { format!("{v:.2}×") }; - egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| { - for v in [1.0, 1.15, 1.5, 2.0] { - ui.selectable_value(&mut e.line_spacing, v, label(v)); - } - }); - }); - ui.horizontal(|ui| { - let l = ui.label(tl!("Character spacing")); - ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id); - }); - ui.horizontal(|ui| { - let l = ui.label(tl!("Horizontal scale")); - ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id); - }); - *e != before -} - impl LineEditor { + /// What Format text's font list shows: the chosen page font, a standard family picked + /// instead, or the paragraph's own font (its `/BaseFont` without the subset tag). + pub(crate) fn font_shown(&self, sources: &[(String, String)]) -> String { + if let Some(face) = self.font_resource.as_ref().and_then(|r| sources.iter().find(|(res, _)| res == r)).map(|(_, face)| face) { + return face.clone(); + } + if self.look.family != self.look0.family || self.family_chosen { + return self.look.family.label().to_string(); + } + let base = self.source.as_ref().map(|(block, _)| block.base_font.as_str()).unwrap_or_default(); + // "ABCDEF+Calibri" is a subset of Calibri. + let name = match base.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => base, + }; + if name.is_empty() { tl!("Keep source fonts").to_string() } else { name.to_string() } + } + + /// Back to the paragraph's own font: no page font chosen and the family it started with. + pub(crate) fn keep_source_font(&mut self) { + self.font_resource = None; + self.look.family = self.look0.family; + self.family_chosen = false; + } + + /// A standard family picked from the font list, replacing the paragraph's font even when it + /// is the family the paragraph's font name reads as. + pub(crate) fn choose_family(&mut self, family: pdfcraft_engine::FontFamily) { + self.font_resource = None; + self.look.family = family; + self.family_chosen = true; + } + pub(crate) fn has_changes(&self) -> bool { - self.text != self.original || self.look != self.look0 || self.extras != self.extras0 + self.text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen } /// Extra width permitted by the editor. Source containers now remain fixed. @@ -229,7 +239,7 @@ impl LineEditor { let (l, o) = (&self.look, &self.look0); let mut style = pdfcraft_engine::BlockStyle { resource: self.font_resource.clone(), - family: (l.family != o.family || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)), + family: (l.family != o.family || self.family_chosen || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)), size: (l.size != o.size).then_some(l.size), color: (l.color != o.color).then_some(l.color), align: (l.align != o.align).then_some(l.align), @@ -267,6 +277,7 @@ impl LineEditor { pub fn applied(&mut self) { self.selection = None; self.font_resource = None; + self.family_chosen = false; self.look0 = self.look.clone(); self.extras0 = self.extras; self.original = self.text.clone(); @@ -965,6 +976,7 @@ fn open_editor( focus: true, look: look_of(l), look0: look_of(l), + family_chosen: false, extras: Extras::default(), extras0: Extras::default(), }); @@ -1177,7 +1189,7 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32 let Some(line) = lines.get(index) else { continue }; let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic); look.size = line.size; - if ed.look.family != ed.look0.family { + if ed.look.family != ed.look0.family || ed.family_chosen { look.family = ed.look.family; } if ed.look.bold != ed.look0.bold { @@ -1193,7 +1205,8 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32 look.size = ed.look.size; } let mut font = editor_font(&look, (look.size as f32 * scale).clamp(1.0, 300.0)); - let original_face = ed.look.family == ed.look0.family && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic; + let original_face = + ed.look.family == ed.look0.family && !ed.family_chosen && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic; let embedded = original_face && ed.fonts.contains_key(&line.font); if embedded && let Some(name) = ed.fonts.get(&line.font) { font.family = FontFamily::Name(name.clone().into()); @@ -1474,12 +1487,31 @@ mod tests { focus: true, look: look_of(&block), look0: look_of(&block), + family_chosen: false, extras: Extras::default(), extras0: Extras::default(), }; (app, ed) } + #[test] + fn picking_the_family_the_font_name_reads_as_still_changes_the_font() { + // A Calibri paragraph reads as Helvetica; picking Helvetica from the list used to leave + // `look` equal to what the paragraph had, so no family change was sent and Calibri stayed. + let (_app, mut ed) = preview_app(); + let family = ed.look0.family; + assert_eq!(ed.style().family, None, "nothing chosen yet"); + ed.choose_family(family); + assert_eq!(ed.style().family, Some((family, ed.look.bold, ed.look.italic))); + assert!(ed.has_changes()); + assert_eq!(ed.font_shown(&[]), family.label()); + ed.keep_source_font(); + assert_eq!(ed.style().family, None, "Keep source fonts withdraws the choice"); + ed.choose_family(family); + ed.applied(); + assert_eq!(ed.style().family, None, "an applied choice is the paragraph's font now"); + } + #[test] fn a_loaded_font_is_applied_to_the_paragraph_being_edited() { // Loading a font used to only register it on the page: the paragraph kept its source diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs new file mode 100644 index 000000000..798b844a9 --- /dev/null +++ b/crates/ui-egui/src/format_text_ui.rs @@ -0,0 +1,333 @@ +//! Format text, laid out as Acrobat's panel: the font across the panel; size, colour, lists and +//! alignment; bold, italic, underline, superscript and subscript; line and paragraph spacing; +//! horizontal scale and character spacing. One panel serves a paragraph being edited (with the +//! fonts already on its page and the spacing controls) and added text (the standard families, +//! size, colour, style and alignment). What PdfCraft can't apply yet (lists, superscript and +//! subscript, paragraph spacing, or spacing for added text) is shown disabled and says so. +//! +//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat. + +use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; +use pdfcraft_engine::{AddedText, FontFamily, TextAlign}; + +use crate::edit_text_ui::Extras; +use crate::theme::Tokens; +use crate::{icons, widgets}; + +/// A font picked from the font list. The caller applies it, since keeping the paragraph's own +/// font also means restoring the family it started with. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum FontChoice { + /// The fonts the paragraph already uses. + KeepSource, + /// One of the page's fonts, by resource name. + Resource(String), + /// A standard family. + Family(FontFamily), + /// Load font…: pick a font file. + Load, + /// A font file installed on this computer. + Installed(std::path::PathBuf), +} + +/// What the font list offers and shows. +pub(crate) struct Fonts<'a> { + /// The fonts on the paragraph's page (resource, face); `None` for added text, which has the + /// standard families only. + pub sources: Option<&'a [(String, String)]>, + /// The name the list shows for the current choice. + pub shown: String, +} + +/// What the panel changed: the style (in place) and a font picked from the list. +#[derive(Default)] +pub(crate) struct Outcome { + pub changed: bool, + pub font: Option, +} + +const SIZES: [f64; 14] = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0]; +const LINE_SPACINGS: [f64; 7] = [1.0, 1.15, 1.2, 1.5, 2.0, 2.5, 3.0]; +const SCALES: [f64; 8] = [50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0, 200.0]; +const CHAR_SPACINGS: [f64; 8] = [-1.0, -0.5, 0.0, 0.5, 1.0, 2.0, 3.0, 5.0]; +/// A row's height: the tallest control in it (the 30 pt icon buttons). Every control is centred +/// on the row, whatever its own height, so lists, swatches and buttons line up (#3037). +const ROW: f32 = 30.0; +/// The gap between a row's controls. +const GAP: f32 = 6.0; +/// The icon naming a list or field ([`labelled_icon`]). +const LABEL_ICON: f32 = 22.0; +/// The button beside a number field that opens its list of common values: just an arrow, so the +/// field keeps the room its value needs. +const PRESET_LIST: f32 = 22.0; +/// What can be typed: horizontal scale in percent, character spacing in points. +const SCALE_RANGE: std::ops::RangeInclusive = 10.0..=400.0; +const CHAR_SPACING_RANGE: std::ops::RangeInclusive = -5.0..=50.0; + +/// A number as the lists show it: no trailing zeros ("1.2", "100", "-0.5"). +fn number(v: f64) -> String { + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: Option<&mut Extras>, fonts: Fonts) -> Outcome { + let before = (look.clone(), extras.as_deref().copied()); + let mut out = Outcome::default(); + widgets::section_title(ui, tl!("Format text")); + + // The font, across the panel. + let width = ui.available_width(); + egui::ComboBox::from_id_salt("format-font") + .selected_text(fonts.shown.as_str()) + .width(width) + .show_ui(ui, |ui| { + if let Some(sources) = fonts.sources { + if ui.selectable_label(false, tl!("Keep source fonts")).clicked() { + out.font = Some(FontChoice::KeepSource); + } + for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) { + if ui.selectable_label(false, face).clicked() { + out.font = Some(FontChoice::Resource(resource.clone())); + } + } + ui.separator(); + } + for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { + if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() { + out.font = Some(FontChoice::Family(f)); + } + } + if fonts.sources.is_some() { + ui.separator(); + if ui.selectable_label(false, tl!("Load font…")).clicked() { + out.font = Some(FontChoice::Load); + } + #[cfg(not(target_arch = "wasm32"))] + ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) { + Some(list) if list.is_empty() => { + ui.label(tl!("No openly licensed document fonts found. Load a font file.")); + } + Some(list) => { + for font in list { + if ui.button(&font.face).clicked() { + out.font = Some(FontChoice::Installed(font.path)); + ui.close(); + } + } + } + None => { + ui.label(tl!("Finding document fonts…")); + } + }); + } + }) + .response + .on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available."))); + ui.add_space(4.0); + + // Size and colour | bullets, numbering and alignment. + row(ui, |ui| { + let _ = egui::ComboBox::from_id_salt("format-size") + .selected_text(number(look.size)) + .width(64.0) + .show_ui(ui, |ui| { + for size in SIZES { + ui.selectable_value(&mut look.size, size, number(size)); + } + }) + .response + .on_hover_text(tl!("Font size")); + let swatch = colour_button(ui, t, look.color); + egui::Popup::menu(&swatch).show(|ui| { + if let Some(picked) = crate::comments::swatch_grid(ui, Some(look.color)) { + look.color = picked; + ui.close(); + } + }); + let (sep, _) = ui.allocate_exact_size(vec2(9.0, 26.0), Sense::hover()); + ui.painter().vline(sep.center().x, sep.y_range().shrink(3.0), Stroke::new(1.0, t.divider)); + planned(ui, "list", tl!("Bulleted list")); + planned(ui, "list-ordered", tl!("Numbered list")); + let current = match look.align { + TextAlign::Left => "align-left", + TextAlign::Center => "align-center", + TextAlign::Right => "align-right", + TextAlign::Justify => "align-justify", + }; + let align = icons::button(ui, current, 30.0, false, tl!("Alignment")); + let align = flyout(ui, t, align); + egui::Popup::menu(&align).show(|ui| { + ui.horizontal(|ui| { + for (a, icon, tip) in [ + (TextAlign::Left, "align-left", tl!("Align left")), + (TextAlign::Center, "align-center", tl!("Centre")), + (TextAlign::Right, "align-right", tl!("Align right")), + (TextAlign::Justify, "align-justify", tl!("Justify")), + ] { + if icons::button(ui, icon, 30.0, look.align == a, tip).clicked() { + look.align = a; + ui.close(); + } + } + }); + }); + }); + + // Bold, italic, underline, superscript, subscript. + let mut extras = extras; + row(ui, |ui| { + if icons::button(ui, "bold", 30.0, look.bold, tl!("Bold")).clicked() { + look.bold = !look.bold; + } + if icons::button(ui, "italic", 30.0, look.italic, tl!("Italic")).clicked() { + look.italic = !look.italic; + } + match extras.as_deref_mut() { + Some(e) => { + if icons::button(ui, "underline", 30.0, e.underline, tl!("Underline")).clicked() { + e.underline = !e.underline; + } + } + None => planned(ui, "underline", tl!("Underline")), + } + planned(ui, "superscript", tl!("Superscript")); + planned(ui, "subscript", tl!("Subscript")); + }); + + // Line and paragraph spacing; horizontal scale and character spacing: two columns, each an + // icon and its control, `half` wide. + let half = ((ui.available_width() - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0); + ui.add_enabled_ui(extras.is_some(), |ui| { + let mut fallback = Extras::default(); + let e = extras.as_deref_mut().unwrap_or(&mut fallback); + row(ui, |ui| { + labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); + let own = tl!("Auto").to_string(); + let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) }; + egui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| { + ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); + for v in LINE_SPACINGS { + ui.selectable_value(&mut e.line_spacing, v, number(v)); + } + }); + ui.add_space(8.0); + ui.add_enabled_ui(false, |ui| { + labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing")); + let tip = format!("{} — {}", tl!("Paragraph spacing"), tl!("Planned")); + let _ = egui::ComboBox::from_id_salt("format-paragraph-spacing") + .selected_text("0") + .width(half) + .show_ui(ui, |_| {}) + .response + .on_disabled_hover_text(tip); + }); + }); + // Any value the engine takes can be typed; the lists hold the common ones. + row(ui, |ui| { + let label = labelled_icon(ui, t, "move-horizontal", tl!("Horizontal scale")); + let scale = Number { value: &mut e.scale, range: SCALE_RANGE, speed: 1.0, suffix: " %" }; + number_with_presets(ui, scale, &SCALES, half, (label.id, tl!("Horizontal scale"))); + ui.add_space(8.0); + let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing")); + let spacing = Number { value: &mut e.char_spacing, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" }; + number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing"))); + }); + }); + + out.changed = (look.clone(), extras.as_deref().copied()) != before; + ui.add_space(6.0); + ui.separator(); + out +} + +/// One row of the panel, [`ROW`] high, its controls [`GAP`] apart and centred on it. +fn row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let size = vec2(ui.available_width(), ROW); + ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = GAP; + // A list lays itself out from the default control height and grows downward, which leaves + // it below the row's middle; at a list's own height it is centred like the rest. + let list = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + ui.spacing_mut().interact_size.y = ui.spacing().interact_size.y.max(list); + add(ui) + }) + .inner +} + +/// A control PdfCraft doesn't have yet: its icon, disabled, saying so when hovered. +fn planned(ui: &mut egui::Ui, icon: &str, label: &str) { + let _ = ui + .add_enabled_ui(false, |ui| icons::button(ui, icon, 30.0, false, label)) + .inner + .on_disabled_hover_text(format!("{label} — {}", tl!("Planned"))); +} + +/// The small corner mark Acrobat puts on a button that opens a menu. +fn flyout(ui: &egui::Ui, t: &Tokens, resp: egui::Response) -> egui::Response { + let r = resp.rect; + let tri = vec![r.right_bottom() + vec2(-3.0, -3.0), r.right_bottom() + vec2(-8.0, -3.0), r.right_bottom() + vec2(-3.0, -8.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri, t.text_faint, Stroke::NONE)); + resp +} + +/// An icon naming the list beside it, for screen readers too. Returns it, for the control it +/// labels. +fn labelled_icon(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(LABEL_ICON, 26.0), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + let tint = if ui.is_enabled() { t.icon } else { t.text_faint }; + icons::paint(ui, Rect::from_center_size(rect.center(), vec2(18.0, 18.0)), icon, 17.0, tint); + resp.on_hover_text(label) +} + +/// A value typed in a number field: what it edits, what may be typed, and how it reads. +struct Number<'a> { + value: &'a mut f64, + range: std::ops::RangeInclusive, + speed: f64, + suffix: &'static str, +} + +/// A number field with an arrow beside it that lists common values, in `width` all told: type any +/// value in its range, or pick one. Both are named by `label`: the icon before them and its text. +fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: f32, label: (egui::Id, &str)) { + ui.scope(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let field = egui::DragValue::new(&mut *n.value).range(n.range).speed(n.speed).max_decimals(2).suffix(n.suffix); + let height = ui.spacing().interact_size.y; + ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0); + let list = icons::button(ui, "chevron-down", PRESET_LIST, false, label.1); + egui::Popup::menu(&list).show(|ui| { + for &v in presets { + if ui.selectable_label(*n.value == v, number(v)).clicked() { + *n.value = v; + ui.close(); + } + } + }); + }); +} + +/// The text colour as a square swatch that opens the palette. +fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour"))); + let [r, g, b] = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let fill = rect.shrink(4.0); + ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b)); + let stroke = if resp.hovered() { t.text } else { t.border }; + ui.painter().rect_stroke(fill, CornerRadius::same(2), Stroke::new(1.0, stroke), egui::StrokeKind::Outside); + crate::theme::hand(resp).on_hover_text(tl!("Colour")) +} + +#[cfg(test)] +mod tests { + #[test] + fn numbers_read_like_the_lists() { + assert_eq!(super::number(1.2), "1.2"); + assert_eq!(super::number(100.0), "100"); + assert_eq!(super::number(-0.5), "-0.5"); + assert_eq!(super::number(0.0), "0"); + assert_eq!(super::number(1.15), "1.15"); + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 4bb58ba8e..68413b493 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2711,5 +2711,11 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة + Bulleted list قائمة نقطية + Numbered list قائمة مرقمة + Alignment المحاذاة + Superscript نص مرتفع + Subscript نص منخفض + Paragraph spacing تباعد الفقرات Less أقل Other options خيارات أخرى diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 584fafda3..dabbc53d7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2397,5 +2397,12 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата + Font Шрифт + Bulleted list Списък с водещи символи + Numbered list Номериран списък + Alignment Подравняване + Superscript Горен индекс + Subscript Долен индекс + Paragraph spacing Разстояние между абзаците Less По-малко Other options Други опции diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d347ca95d..3d6d65baf 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2508,5 +2508,12 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky + Font Písmo + Bulleted list Odrážkový seznam + Numbered list Číslovaný seznam + Alignment Zarovnání + Superscript Horní index + Subscript Dolní index + Paragraph spacing Mezera mezi odstavci Less Méně Other options Další možnosti diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cb1ffc2fd..916a03b94 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2435,5 +2435,12 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften + Font Schrift + Bulleted list Aufzählung + Numbered list Nummerierte Liste + Alignment Ausrichtung + Superscript Hochgestellt + Subscript Tiefgestellt + Paragraph spacing Absatzabstand Less Weniger Other options Weitere Optionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2bde0b50b..6ace4f902 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2440,5 +2440,12 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας + Font Γραμματοσειρά + Bulleted list Λίστα με κουκκίδες + Numbered list Αριθμημένη λίστα + Alignment Στοίχιση + Superscript Εκθέτης + Subscript Δείκτης + Paragraph spacing Απόσταση παραγράφων Less Λιγότερα Other options Άλλες επιλογές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fbc9f54c9..07c1bfd6c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2570,6 +2570,13 @@ animation effects Off Desactivados Make properties default Usar como propiedades predeterminadas Pages selected Páginas seleccionadas Page properties Propiedades de página + Font Fuente + Bulleted list Lista con viñetas + Numbered list Lista numerada + Alignment Alineación + Superscript Superíndice + Subscript Subíndice + Paragraph spacing Espaciado entre párrafos Less Menos Other options Otras opciones diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bde247a8d..59a2c74f1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2434,5 +2434,12 @@ animation effects Off Désactivés Make properties default Définir comme propriétés par défaut Pages selected Pages sélectionnées Page properties Propriétés de la page + Font Police + Bulleted list Liste à puces + Numbered list Liste numérotée + Alignment Alignement + Superscript Exposant + Subscript Indice + Paragraph spacing Espacement des paragraphes Less Moins Other options Autres options diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 16efc30a0..6fb896ea3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2433,5 +2433,12 @@ animation effects Off Ki Make properties default Tulajdonságok beállítása alapértelmezettként Pages selected Kijelölt oldalak Page properties Oldaltulajdonságok + Font Betűtípus + Bulleted list Felsorolás + Numbered list Számozott lista + Alignment Igazítás + Superscript Felső index + Subscript Alsó index + Paragraph spacing Bekezdéstávolság Less Kevesebb Other options Egyéb beállítások diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c46f9cf81..e3390dfb1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2646,5 +2646,12 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 + Font Carattere + Bulleted list Elenco puntato + Numbered list Elenco numerato + Alignment Allineamento + Superscript Apice + Subscript Pedice + Paragraph spacing Spaziatura paragrafi Less Meno Other options Altre opzioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 54bc54633..84b45a9cd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2434,5 +2434,12 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ + Font フォント + Bulleted list 箇条書き + Numbered list 番号付きリスト + Alignment 配置 + Superscript 上付き + Subscript 下付き + Paragraph spacing 段落間隔 Less 折りたたむ Other options その他のオプション diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 5ca11b941..4306b5055 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2432,5 +2432,12 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері + Font Қаріп + Bulleted list Таңбалауыш тізім + Numbered list Нөмірленген тізім + Alignment Туралау + Superscript Жоғарғы индекс + Subscript Төменгі индекс + Paragraph spacing Абзац аралығы Less Азырақ Other options Басқа параметрлер diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 2674dfc86..5a5306ba0 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2685,5 +2685,11 @@ key Space Tarpas The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda + Bulleted list Sąrašas su ženkleliais + Numbered list Numeruotas sąrašas + Alignment Lygiuotė + Superscript Viršutinis indeksas + Subscript Apatinis indeksas + Paragraph spacing Tarpas tarp pastraipų Less Mažiau Other options Kitos parinktys diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d6d5e5e93..2d423c2a2 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2410,5 +2410,12 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony + Font Czcionka + Bulleted list Lista punktowana + Numbered list Lista numerowana + Alignment Wyrównanie + Superscript Indeks górny + Subscript Indeks dolny + Paragraph spacing Odstęp między akapitami Less Mniej Other options Inne opcje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ee041f4af..2729e2b97 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2448,5 +2448,12 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página + Font Fonte + Bulleted list Lista com marcadores + Numbered list Lista numerada + Alignment Alinhamento + Superscript Sobrescrito + Subscript Subscrito + Paragraph spacing Espaçamento entre parágrafos Less Menos Other options Outras opções diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 72fcb1ddc..af7dc384b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2347,5 +2347,12 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы + Font Шрифт + Bulleted list Маркированный список + Numbered list Нумерованный список + Alignment Выравнивание + Superscript Надстрочный + Subscript Подстрочный + Paragraph spacing Интервал между абзацами Less Меньше Other options Другие параметры diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 009131307..fd00ce09e 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2439,5 +2439,12 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani + Font Pisava + Bulleted list Označen seznam + Numbered list Oštevilčen seznam + Alignment Poravnava + Superscript Nadpisano + Subscript Podpisano + Paragraph spacing Razmik med odstavki Less Manj Other options Druge možnosti diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 80008d191..f7e844496 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2343,5 +2343,12 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు + Font ఫాంట్ + Bulleted list బుల్లెట్ జాబితా + Numbered list సంఖ్యల జాబితా + Alignment అమరిక + Superscript సూపర్‌స్క్రిప్ట్ + Subscript సబ్‌స్క్రిప్ట్ + Paragraph spacing పేరా అంతరం Less తక్కువ Other options ఇతర ఎంపికలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b185f0262..925ea2c4b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2430,5 +2430,12 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri + Font Yazı tipi + Bulleted list Madde işaretli liste + Numbered list Numaralı liste + Alignment Hizalama + Superscript Üst simge + Subscript Alt simge + Paragraph spacing Paragraf aralığı Less Daha az Other options Diğer seçenekler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c25eba25b..a64c31fe0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2456,5 +2456,12 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки + Font Шрифт + Bulleted list Маркований список + Numbered list Нумерований список + Alignment Вирівнювання + Superscript Надрядковий + Subscript Підрядковий + Paragraph spacing Інтервал між абзацами Less Менше Other options Інші параметри diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index dbf9eb224..b2a99ef4d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2683,6 +2683,12 @@ key Space Space Words Từ “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này. @id file.word_count Đếm từ… + Bulleted list Danh sách dấu đầu dòng + Numbered list Danh sách đánh số + Alignment Căn lề + Paragraph spacing Khoảng cách đoạn + Superscript Chỉ số trên + Subscript Chỉ số dưới Less Ít hơn Other options Tùy chọn khác diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a71b3f30e..80623decf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2398,5 +2398,12 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 + Font 字体 + Bulleted list 项目符号列表 + Numbered list 编号列表 + Alignment 对齐 + Superscript 上标 + Subscript 下标 + Paragraph spacing 段落间距 Less 收起 Other options 其他选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 630bfbf34..cd8a1051e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2384,5 +2384,12 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 + Font 字型 + Bulleted list 項目符號清單 + Numbered list 編號清單 + Alignment 對齊 + Superscript 上標 + Subscript 下標 + Paragraph spacing 段落間距 Less 收起 Other options 其他選項 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 6e3f26f57..f986cd6bf 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -185,6 +185,10 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("user-round", include_bytes!("../../../assets/icons/user-round.svg")), ("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")), ("wrench", include_bytes!("../../../assets/icons/wrench.svg")), + ("move-horizontal", include_bytes!("../../../assets/icons/move-horizontal.svg")), + ("pilcrow", include_bytes!("../../../assets/icons/pilcrow.svg")), + ("subscript", include_bytes!("../../../assets/icons/subscript.svg")), + ("superscript", include_bytes!("../../../assets/icons/superscript.svg")), ("x", include_bytes!("../../../assets/icons/x.svg")), ("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")), ("zoom-out", include_bytes!("../../../assets/icons/zoom-out.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index bd084333b..79254afa2 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -99,7 +99,7 @@ mod tests { #[test] fn shared_catalog_preserves_existing_icons_and_fallback_uri() { - assert_eq!(ICONS.len(), 186); + assert_eq!(ICONS.len(), 190); assert!(exists("minimize-2")); for (name, original) in ICONS { let expected = diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34e7f4b61..ba4fc21fc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,7 @@ mod files; pub mod fill_sign; mod fling; pub mod folders_ui; +mod format_text_ui; pub mod forms_ui; mod home; mod icon_data; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 8d6660b64..829ebad7e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -516,50 +516,42 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { if let Some((i, _)) = app.active_ids() && let Some(ed) = app.views[i].line_editor.clone() { + use crate::format_text_ui::{FontChoice, Fonts}; let mut ed = ed; - let mut changed = false; - let font_choices = app.session.get(app.views[i].id).map(|d| d.text_editor_fonts(ed.page)).unwrap_or_default(); - egui::ComboBox::from_id_salt("document-font-resource") - .selected_text( - ed.font_resource - .as_ref() - .and_then(|r| font_choices.iter().find(|f| &f.resource == r)) - .map_or(tl!("Keep source fonts"), |f| f.face.as_str()), - ) - .show_ui(ui, |ui| { - changed |= ui.selectable_value(&mut ed.font_resource, None, tl!("Keep source fonts")).changed(); - for font in font_choices.iter().filter(|f| !f.resource.starts_with("PCApproved")) { - changed |= ui.selectable_value(&mut ed.font_resource, Some(font.resource.clone()), &font.face).changed(); - } - }); - if ui.button(tl!("Load font…")).clicked() { - app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false); - } - #[cfg(not(target_arch = "wasm32"))] - egui::ComboBox::from_id_salt("installed-document-font").selected_text(tl!("Installed fonts…")).show_ui(ui, |ui| { - if let Some(fonts) = crate::files::installed_font_files(ui.ctx()) { - if fonts.is_empty() { - ui.label(tl!("No openly licensed document fonts found. Load a font file.")); - } - for font in fonts { - if ui.selectable_label(false, &font.face).clicked() { - app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[font.path]); - } - } - } else { - ui.label(tl!("Finding document fonts…")); + let sources: Vec<(String, String)> = app + .session + .get(app.views[i].id) + .map(|d| d.text_editor_fonts(ed.page)) + .unwrap_or_default() + .into_iter() + .map(|f| (f.resource, f.face)) + .collect(); + let shown = ed.font_shown(&sources); + let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }); + let mut changed = out.changed; + match out.font { + Some(FontChoice::KeepSource) => { + ed.keep_source_font(); + changed = true; } - }); - if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) { - ed.look = look; - changed = true; + Some(FontChoice::Resource(resource)) => { + ed.font_resource = Some(resource); + changed = true; + } + Some(FontChoice::Family(family)) => { + ed.choose_family(family); + changed = true; + } + Some(FontChoice::Load) => app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false), + #[cfg(not(target_arch = "wasm32"))] + Some(FontChoice::Installed(path)) => app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[path]), + #[cfg(target_arch = "wasm32")] + Some(FontChoice::Installed(_)) => {} + None => {} } - changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); if changed { app.apply_line_format(i, ed, Some(ui.ctx())); } - ui.add_space(6.0); - ui.separator(); return; } let image = app.active_ids().and_then(|(i, id)| { @@ -593,21 +585,32 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { }); match selected { Some((page, index, text)) => { - if let Some(style) = crate::content_ui::format_panel(ui, t, &text) { - app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style.clone() }; - app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style) }); + let mut style = text.clone(); + added_text_format(ui, t, &mut style); + if style != text { + // The reusable style follows the selection only when the edit took, so a refused one adds nothing odd later. + if app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style.clone()) }) { + app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style }; + } } } None if app.quick_tool == crate::QuickTool::AddText => { crate::content_ui::hint(ui, t); - if let Some(style) = crate::content_ui::format_panel(ui, t, &app.text_style) { - app.text_style = style; - } + let mut style = app.text_style.clone(); + added_text_format(ui, t, &mut style); + app.text_style = style; } - None => return, + None => {} + } +} + +/// Format text for added text: the standard families, size, colour, style and alignment. +fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText) { + let shown = style.family.label().to_string(); + let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }); + if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font { + style.family = family; } - ui.add_space(6.0); - ui.separator(); } impl PdfCraftApp { diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index dd2c032ef..70e5a0dcb 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -236,7 +236,7 @@ fn typing_moving_styling_and_deleting_added_content() { h.run_steps(2); assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the new text is selected"); // Bold from the Format panel: one undoable change. - h.get_by_label("B").click(); + h.get_by_label("Bold").click(); h.run_steps(3); let pdfcraft_engine::AddedContent::Text(t) = &added(&h)[0].content else { panic!() }; @@ -585,3 +585,60 @@ fn released_text_does_not_jump_back_before_the_page_is_redrawn() { let (old, new) = ink_at(&mut h, from, to); assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)"); } + +/// Format text is laid out as Acrobat's: alignment from its flyout, style buttons by name, and +/// the controls PdfCraft can't apply yet present but disabled. +/// Format text lines its rows up (#3037): the size list is centred beside the colour swatch and +/// the alignment button and is as tall as the font list, each spacing row's controls share one +/// line, and nothing reaches past the font list, which spans the panel. +#[test] +fn format_text_rows_line_up_and_fit_the_panel() { + use egui::accesskit::Role; + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.get_by_label("FORMAT TEXT"); + let combo = |h: &Harness<'static, PdfCraftApp>, value: &str| { + h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some(value)).map(|n| n.rect()).expect(value) + }; + let (font, size) = (combo(&h, "Helvetica"), combo(&h, "12")); + let middle = |r: egui::Rect| r.center().y; + for label in ["Colour", "Alignment"] { + let other = h.get_by_label(label).rect(); + assert!((middle(size) - middle(other)).abs() < 0.5, "the size list ({size:?}) is centred with {label} ({other:?})"); + } + assert_eq!(size.height(), font.height(), "the font and size lists are the same height"); + // Line and paragraph spacing; scale and character spacing with their lists. + let line = [combo(&h, "Auto"), combo(&h, "0")]; + assert!((middle(line[0]) - middle(line[1])).abs() < 0.5, "{line:?}"); + // Each: its number field and the arrow that lists common values. + let scale_row: Vec = + ["Horizontal scale", "Character spacing"].iter().flat_map(|l| h.query_all_by_label(l).map(|n| n.rect())).collect(); + assert_eq!(scale_row.len(), 4, "two fields and their two arrows: {scale_row:?}"); + for r in &scale_row { + assert!((middle(*r) - middle(scale_row[0])).abs() < 0.5, "{scale_row:?}"); + } + for r in h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).chain(scale_row) { + assert!(r.right() <= font.right() + 0.5, "{r:?} reaches past the panel ({font:?})"); + } +} + +#[test] +fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { + use egui_kittest::kittest::NodeT; + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.get_by_label("FORMAT TEXT"); + for planned in ["Bulleted list", "Numbered list", "Superscript", "Subscript"] { + assert!(h.get_by_label(planned).accesskit_node().is_disabled(), "{planned} is shown but disabled"); + } + h.get_by_label("Alignment").click(); + h.run_steps(2); + h.get_by_label("Centre").click(); + h.run_steps(2); + assert_eq!(h.state().text_style.align, pdfcraft_engine::TextAlign::Center); + h.get_by_label("Bold").click(); + h.run_steps(2); + assert!(h.state().text_style.bold); +} diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index cced4fe03..6885a2bb6 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3028,7 +3028,7 @@ fn the_format_panel_restyles_the_paragraph_being_edited() { assert!(h.state().views[0].line_editor.is_some()); // The Format text panel shows the paragraph's look; making it bold applies at once. h.get_by_label("FORMAT TEXT"); - h.get_by_label("B").click(); + h.get_by_label("Bold").click(); h.run_steps(4); let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); @@ -3044,6 +3044,62 @@ fn the_format_panel_restyles_the_paragraph_being_edited() { assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline)); } +/// Horizontal scale and Character spacing take any value in their ranges, not only the listed +/// ones: values between the listed ones and beyond them, typed in Format text, reach the saved +/// file as the engine writes them. +#[test] +fn the_format_panel_takes_any_scale_and_character_spacing() { + let mut h = harness(1, |_| {}); + let id = h.state().views[0].id; + let width = |h: &Harness<'static, PdfCraftApp>, view: usize| { + let s = h.state(); + let r = s.session.get(s.views[view].id).unwrap().text_blocks(0)[0].rect; + r[2] - r[0] + }; + let plain = width(&h, 0); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some()); + // Typed the way a person does: click the field, type, Enter. + let type_in = |h: &mut Harness<'static, PdfCraftApp>, label: &str, value: &str| { + h.get_by_role_and_label(Role::SpinButton, label).click(); + h.run_steps(2); + h.event(egui::Event::Text(value.into())); + h.run_steps(1); + h.key_press(Key::Enter); + h.run_steps(4); + }; + let extras = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].line_editor.as_ref().map(|e| (e.extras.scale, e.extras.char_spacing)); + // Between the listed values (50…200 %, −1…5 pt) and beyond them, within what was always allowed. + type_in(&mut h, "Horizontal scale", "80"); + assert_eq!(extras(&h), Some((80.0, 0.0))); + type_in(&mut h, "Horizontal scale", "250"); + assert_eq!(extras(&h), Some((250.0, 0.0))); + type_in(&mut h, "Character spacing", "0.25"); + assert_eq!(extras(&h), Some((250.0, 0.25))); + type_in(&mut h, "Character spacing", "-2"); + assert_eq!(extras(&h), Some((250.0, -2.0))); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text")); + // Saved and reopened, the paragraph is as wide as the engine draws it with those values. + let bytes = h.state().session.save_bytes(id).unwrap(); + h.state_mut().open_bytes("reference.pdf", None, fixture(1)).expect("opens"); + let style = pdfcraft_engine::BlockStyle { scale: Some(250.0), char_spacing: Some(-2.0), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::EditTextBlock { page: 0, block: 0, text: "Page 1".into(), style })); + h.state_mut().open_bytes("saved.pdf", None, bytes.as_ref().clone()).expect("the saved file opens"); + h.run_steps(3); + let (reference, saved) = (width(&h, 1), width(&h, 2)); + assert!(saved > plain * 1.5, "250 % widens it: {plain} → {saved}"); + assert!((saved - reference).abs() < 0.5, "saved {saved}, the engine's own {reference}"); +} + #[test] fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() { let mut h = harness(1, |_| {}); @@ -3062,7 +3118,7 @@ fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() { h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); h.run_steps(2); assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, Some(5..6)); - h.get_by_label("B").click(); + h.get_by_label("Bold").click(); h.run_steps(5); let id = h.state().views[0].id; let doc = h.state().session.get(id).unwrap(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 95443d57d..42bbe8352 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1986,8 +1986,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] -notes = "character spacing in points and horizontal scaling in percent for a paragraph (Tc, Tz), rewrapped" +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"] +notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list" [[feature]] id = "edit.lists" @@ -2592,8 +2592,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] -notes = "Edit a PDF ▸ Format text shows the paragraph being edited (family, bold/italic guessed from its font, size) and applies changes at once as one undo step each, keeping the paragraph open" +evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/content.rs::format_text_rows_line_up_and_fit_the_panel"] +notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline on added text." [[feature]] id = "edit.adobe-express-edits"