diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index f1a544ebd..a4bbd27e4 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 = "380a21460c6fff6df7ae0a2ef563e5df5a8320dba53bcae483f9172a0123d69d" +sha256 = "a7582830210ccda4e50f54a53d4abcba9aec5fe4f26c8b7dcbed1e0d6a45b944" # 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 = "19fb102db12d172a4bfca18e22b2acae1fd51b36adc80b2ace5e5aa66b8249da" +sha256 = "f91d2af486b14b63cd7374eda18d7d4755ff31ad1c796a6164d207a80a87be64" [[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 = "d7c8c50f5113f5bcb46dcce711c79701a5d7d9e05927146d0b2de9d99dbf1d92" +sha256 = "64ecf43f271479518206ed085b2573fbee77879d82b45bc8381e844e216e804f" [[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 = "4d8119b38701c0b178d36afe4df0ad6b1cfd78a5a164062d615133b9b205e515" +sha256 = "cc4bc44013c6129d1d27adfc6c448c93466fbb0327a13def6f509f55213e2bfb" [[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 = "79f305d74e4a58897de55abe2b4ae5f3b3651e6946bb377fff5d7747648a60f5" +sha256 = "623ba09938dc665f64e38ce6adfc47404227fef8be96aae1f431db5f9d7269e3" [[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 = "7d7e1ce33764d838993a26afaf68d66c8e39e9dfc1f238954bd6dd2380798fe4" +sha256 = "3463f5e26e0c928027606cd1dc83d182a9a4f821e186b387423890148b269ab2" [[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 = "83f3287fe2158900793aee2425b56d058573b8b62f274bb3da935a55646ebe44" +sha256 = "5b1a8936d734961945f6fa313043ca9560627e4a9749c8c901f4734b02bece1d" [[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 = "f79bb5ebbec462b3c662fb6ac2175b0983dd8d153b8d1b672fa18e13f0576d70" +sha256 = "2763ca6a5589a2267ca932f765f2dc67d7e4def335a119cf8ea8db6076d39f29" [[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 = "add4434d161501a2d1848f4b672922ddd66c49dd17d3d14a4d0e385feb43285f" +sha256 = "cc345a27318a92dcb3708ba124ec0288bc263b566134e9380dcc9c3662ce5212" [[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 = "01b5915d4d35318d6ecc82d9f99ffee41155a5c7ac358e5c6eaa269474b8aede" +sha256 = "109a054417cc3fd002e6d5478e2777490bf8694a4e4689df141e80a54bf89857" [[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 = "780a710c4baaff41e2cf2e290d77ea09baa179b3566eea0a9be0292f61ca2cb4" +sha256 = "859903397c8ee9468c1fdd867a38f7ef832c0b48043577370ab0835e030d98a7" [[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 = "d7547d9f20841485234618a260c7982fef9214c7eb70b132780e3b1737075ce0" +sha256 = "99abf57aa923e7b27027b5079ab261a0652d13c5f49f072bd3b0c0e61c3d2669" [[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 = "d31d3addabfe9553f94760f6e267ab10c244738c90ce66aaf30f0eb36b6ec865" +sha256 = "1a05b28d5b0ab067205ce9c08af0155d8b58512be4119bd1174ec772d6a3932b" [[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 = "58dae1284c97aac4f3a142804ac5c685a8a7c35918f3ae156862e3abc1223c17" +sha256 = "4ea214bbabd8c3e16475cd8f582286f0e795b00f1c2f1522fe0d70c145d2c159" [[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 = "9ce954e1ee23680d23bb400e45386f32c3337e92efe69c822530baa79b9894cf" +sha256 = "3f64cc060a9981b69387b88c68a2dd7f0fdc69cab61bdad8e62f51dd521a179d" [[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 = "e7120ab2b8499639534931873526019c7ba8c90e2dc5cec64e631bdbaa046775" +sha256 = "d08fa0f9bf6c0a44221b6909965f604b6aa0bde5a9aa6733cb77d6b359c0b5f6" [[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 = "03f9087ecd0f95b238a4aa057038988d9f4fe18f7b21919e3fd87332a53fcdb2" +sha256 = "b35ab82f4b6c04e7265965c7a6ea71e8961366bc0d422dd9e52507078acdea7a" [[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 = "0c6c11f737cd9ad73bfb7f362ca0754fc010d7d0f54726344fe49ae97cc5a0ad" +sha256 = "7d4c96e2c3c0c1a58eb72c5ebbe2d5bf4d119f04f54d9cb680a13403953a4928" [[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 = "4d1b9618321a3fa05896eec924f54c8d265742729032335c18863724333b74b6" +sha256 = "dfb6f207a89a0b327569f31f66d211500207aff2ac9a6f3fb9aa825645967509" [[asset]] path = "assets/icons/accessibility.svg" @@ -3339,4 +3339,4 @@ 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 = "9fe1f01f044766434ccaad74556a3af5ec99591d61f5d4d2a4967c7bfbfc92c3" +sha256 = "000d35f32ae561c4ee7ec2a318bbcc3239f42be1121b0b3d9882688a98245316" diff --git a/Cargo.lock b/Cargo.lock index 1f2d2224f..3911d7385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3700,6 +3700,7 @@ name = "pdfcraft-edit" version = "0.6.0" dependencies = [ "hayro-syntax", + "kurbo 0.13.1", "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", @@ -3770,6 +3771,7 @@ dependencies = [ "harfrust", "pdfcraft-cos", "skrifa 0.44.0", + "unicode-bidi", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 2fd38f608..a645fb208 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,18 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged. - **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged. +- **2026-10-11 (contributor fork, M7):** Full matching font import completes source-subset glyphs; installed/file font selection, TrueType/OpenType-CFF mapping, disjoint range styles, marked paragraph boundaries and single-face contextual shaping/bidi added. Private Chinese keyboard edits on all four pages preserve source anchors and save/reopen renders. Curved clips exclude holes from hit tests; shared path scopes are isolated for image transforms without moving neighbouring paint. No font binary or private fixture is published; release font inputs and readiness estimates unchanged. + +- **2026-10-11 (M7, cropped image resize follow-up):** Resize from the visible (clipped) frame; an image's own clip moves with it (#1049) and shared clips stay fixed. Selected corner grips keep control over overlapping text; horizontal/vertical-only shrinking responds without freezing; one drag frame is previewed and one edit is applied on release. Synthetic pointer/tool regressions and private grow/shrink/save/reopen/undo rendering checks cover the regression. Shared clipping scopes containing unrelated painted content are refused. Estimates unchanged. + +- **2026-10-11 (M7, cropped artwork selection):** Image/Form inventories now expose page/path-clipped visible bounds while retaining full placement geometry for transforms. Edit clicks prioritize text over background artwork; image frames stay within their own page and fixed clips remain fixed during movement. Engine/tool/UI regressions and an opt-in four-page private-file test cover direct access to 73 text regions and five cropped placements. Rectangular clips are exact; curved/complex clips use conservative bounds. Estimates unchanged. + +- **2026-10-11 (M7, live text editor follow-up):** Reuse validated embedded sfnt source faces; adapt Mac format-6 subset cmaps through PDF Unicode mappings in memory without changing or installing source fonts. Preserve source baseline positions on entry. Show draft text and a single frame that grows/shrinks as rows are added or removed; single rows can grow toward the page edge. `text_edit_layout` exposes read-only draft bounds through the engine and automation. Licensed synthetic font/frame regressions and an opt-in all-page private-file check cover source font usage, stable positions, typing and deletion. Unsupported source programs, shaping and installed-font selection remain limited; estimates unchanged. + +- **2026-10-11 (M7, mixed-style regions follow-up):** Group adjacent source runs into visual rows and aligned rows into regions across subset fonts, colours, weights and text-matrix sizes. One inline TextEdit uses styled spans and source row breaks; rewriting preserves surviving source-run styles, source subset encodings and centered mixed-size title alignment. Added engine, automation and UI regressions for edit/delete, read-only preview, save/reopen and undo; privately verified the supplied real PDF's three-row title and six-row body, including selecting both in one editor without extra rows. `text_paragraphs` exposes `editor_text` and `rows`. Installed-font selection, exact editor glyphs, complex shaping and arbitrary multi-range style edits remain open. Readiness estimates unchanged. + +- **2026-10-11 (M7, contributor fork):** Join compatible words across text objects and subset-font spaces; preserve source fonts that lack a space glyph using text positioning; recognize component colour operators. Inline editing uses one editor frame, resets its available width when switching blocks, keeps source line breaks, and renders a COS-clone background without the original glyphs, preserving unselected text advances. `page_render.hide_text_block` exposes the same read-only preview. Mixed-style paragraph grouping, installed-font selection and exact source-font editor rendering remain open. + Newest first: the date, what moved, and the new overall estimate. - **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes. @@ -364,6 +376,7 @@ Newest first: the date, what moved, and the new overall estimate. |---|---|---| | 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. | | 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged | +| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged | | 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) | | 2026-10-10 | minor | Essentials-user score added (≈ 66%) | | 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha | diff --git a/crates/automation/README.md b/crates/automation/README.md index 29f130ee3..971125024 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -118,3 +118,19 @@ Exports complete synchronously. `progressToken` and cancellation notifications a ignored; no background-job or cancellation behavior is added. Session resources `pdfcraft://document` and `pdfcraft://commands`, and the refusal of unknown argument keys, are described in [docs/mcp.md](../../docs/mcp.md). + +For inline editor previews, `page_render` accepts `hide_text_block` (a 1-based index from +`text_paragraphs`). It renders the page without that paragraph’s source glyphs and leaves the +working document unchanged. + + +## Existing text and document fonts + +`text_font_files` discovers eligible installed files on request (or specified confined folders), +`text_font_import` registers a full document face, and `text_fonts` lists source/full resources. +Use `text_edit.font_resource` to choose a resource, or keep source faces and let matching full +faces complete absent subset glyphs. `text_edit.ranges` accepts disjoint UTF-8 byte ranges; +`text_edit_layout` measures the same resource/range draft without committing. All changes are +undoable and survive saving/reopening. `image_hit` shares the UI's curve and hole hit testing. +The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md) +and [image editing](../../docs/editing-images.md). diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 327a740d4..5b84d93d5 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -390,6 +390,21 @@ impl Automation { .collect(); json!({ "page": page + 1, "count": lines.len(), "lines": lines }) } + "image_hit" => { + let page = self.page(&a)?; + let point = a + .get("point") + .and_then(Value::as_array) + .filter(|p| p.len() == 2) + .and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) + .filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9)) + .ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?; + let doc = self.doc(&a)?; + let p = &doc.info.pages[page]; + let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from); + let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect(); + json!({"page":page+1,"images":images}) + } "page_images" => { let page = self.page(&a)?; let doc = self.doc(&a)?; @@ -401,7 +416,11 @@ impl Automation { .enumerate() .map(|(i, im)| { let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); - json!({ "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) + let visible = im.visible_rect.map(|rect| { + let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); + [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] + }); + json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) }) .collect(); json!({ "page": page + 1, "count": list.len(), "images": list }) @@ -470,6 +489,8 @@ impl Automation { json!({ "paragraph": i + 1, "text": b.text, + "editor_text": b.editor_text, + "rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::>()).collect::>(), "lines": b.lines.iter().map(|l| l + 1).collect::>(), "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "font": b.base_font, @@ -479,6 +500,78 @@ impl Automation { .collect(); json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) } + "text_font_files" => { + let roots = if let Some(paths) = a.get("directories") { + let paths = paths + .as_array() + .filter(|p| p.len() <= 16) + .ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?; + paths + .iter() + .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) + .collect::>>()? + } else if let Some(root) = &self.root { + vec![root.clone()] + } else { + pdfcraft_engine::text_fonts::installed_directories() + }; + json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::>()}) + } + "text_font_import" => { + let page = self.page(&a)?; + let path = self.resolve(a.str("path")?, false)?; + let file = std::fs::File::open(&path).map_err(failed)?; + use std::io::Read; + let mut bytes = Vec::new(); + file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?; + if bytes.len() > 16 << 20 { + return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into())); + } + let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect(); + let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?; + let added = self + .doc(&a)? + .text_editor_fonts(page) + .into_iter() + .find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved")); + if let Some(f) = added { + out["resource"] = json!(f.resource); + out["font"] = json!(f.face); + } + out + } + "text_fonts" => { + let page = self.page(&a)?; + json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::>()}) + } + "text_edit_layout" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let k = a.int("paragraph")?; + let blocks = doc.text_blocks(page); + if k < 1 || k as usize > blocks.len() { + return Err(ToolError::InvalidArgs("paragraph is out of range".into())); + } + let source = &blocks[k as usize - 1]; + let text = a.opt_str("text")?.unwrap_or(&source.editor_text); + let style = pdfcraft_engine::BlockStyle { + size: a.opt_num("size")?, + width: a.opt_num("width")?, + resource: a.opt_str("font_resource")?.map(str::to_owned), + ranges: parse_style_ranges(a.get("ranges"))?, + ..Default::default() + }; + let layout = doc.text_edit_layout(page, k as usize - 1, text, &style).map_err(failed)?; + let info = &doc.info.pages[page]; + let view_rect = |r: [f64; 4]| { + let a = info.user_to_view(r[0] as f32, r[1] as f32); + let b = info.user_to_view(r[2] as f32, r[3] as f32); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + }; + let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect(); + json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect), + "draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts }) + } "text_edit" if a.get("paragraph").is_some() => { let page = self.page(&a)?; let n = self.doc(&a)?.text_blocks(page).len(); @@ -489,6 +582,8 @@ impl Automation { let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone(); let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); let mut style = pdfcraft_engine::BlockStyle { + ranges: parse_style_ranges(a.get("ranges"))?, + resource: a.opt_str("font_resource")?.map(str::to_owned), bold: a.opt_bool("bold")?, size: a.opt_num("size")?, underline: a.opt_bool("underline")?, @@ -1555,14 +1650,16 @@ impl Automation { if !(1.0..=MAX_DPI).contains(&dpi) { return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); } - let out = self.renderer(id)?.render(RenderRequest { - page, - kind: RequestKind::Pixels, - tile: None, - scale: (dpi / 72.0) as f32, - tag: 0, - preview: false, - }); + let scale = (dpi / 72.0) as f32; + let out = if let Some(block) = a.opt_int("hide_text_block")? { + let index = usize::try_from(block) + .ok() + .and_then(|n| n.checked_sub(1)) + .ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?; + self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else { + self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) + }; if let Some(e) = out.error { return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); } @@ -1784,6 +1881,53 @@ fn foreign_share(path: &Path, root: &Path) -> bool { // ---- JSON helpers ---------------------------------------------------------------------------- +fn parse_style_ranges(value: Option<&Value>) -> Result> { + let Some(value) = value else { return Ok(Vec::new()) }; + let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; + values + .iter() + .map(|v| { + let a = Args(v); + let bounds = a + .get("range") + .and_then(Value::as_array) + .filter(|v| v.len() == 2) + .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; + let start = bounds + .first() + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let end = bounds + .get(1) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let family = a + .opt_str("font")? + .map(|f| match f { + "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), + "times" => Ok(pdfcraft_engine::FontFamily::Times), + "courier" => Ok(pdfcraft_engine::FontFamily::Courier), + _ => Err(Args::wrong("font", "helvetica, times or courier")), + }) + .transpose()? + .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) + .transpose()?; + Ok(pdfcraft_engine::RangeStyle { + range: start..end, + family, + resource: a.opt_str("font_resource")?.map(str::to_owned), + size: a.opt_num("size")?, + color: a.opt_str("color")?.map(comments::parse_color).transpose()?, + underline: a.opt_bool("underline")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + }) + }) + .collect() +} + /// Typed access to validated arguments. struct Args<'a>(&'a Value); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 76f601a7b..0f2e65f43 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -149,7 +149,9 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .ro() - .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 } }), &["doc", "page"])), + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 }, + "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." } + }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.") @@ -1085,11 +1087,15 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, box (top-left-origin points), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.") + t("image_hit", "Hit test page artwork", "List image/Form numbers whose placement and all clipping paths contain point [x,y] in top-left-origin points. Bezier curves, even-odd/nonzero fill and holes are respected. Read only.") + .ro() + .cmd("edit.edit_text") + .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}}), &["doc","page","point"])), + t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, full placement rect and visible_rect after page/path clipping (top-left-origin points; null if fully hidden), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.") .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new box in top-left-origin points), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") + t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") .cmd("edit.edit_text") .with(schema( json!({ @@ -1107,15 +1113,30 @@ pub fn tools() -> Vec { .destructive() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, "path": { "type": "string" } }), &["doc", "page", "image", "path"])), - t("text_paragraphs", "List paragraphs", "The paragraphs on a page (lines grouped by font, size, alignment and spacing): number, text, its line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.") + t("text_font_files", "List installed document fonts", "Discover openly licensed, embeddable TrueType/OpenType files on request. Optional directories (at most 16) restrict the scan; with --root, only that root is scanned by default and supplied folders must stay inside it. The scan skips symlinks, collections and invalid/licence-restricted fonts, with depth, entry and byte budgets. Select a returned path with text_font_import.") + .ro().cmd("edit.edit_text") + .with(schema(json!({"directories":{"type":"array","items":{"type":"string"},"maxItems":16}}), &[])), + t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.") + .cmd("edit.edit_text") + .with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])), + t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.") + .ro().cmd("edit.edit_text") + .with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])), + t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.") .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.") + t("text_edit_layout", "Measure text draft", "Measure a paragraph draft using the same PDF rewrite as Apply, without changing the document. Returns source_rect, draft_rect (top-left-origin points), overflow, and the embedded sfnt source faces available to the inline editor. Text defaults to the unchanged paragraph. Optional size, width, font_resource and disjoint UTF-8 ranges preview formatting changes.") + .ro() + .cmd("edit.edit_text") + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])), + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, + "font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." }, + "ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." }, "text": { "type": "string" }, "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "bold": { "type": "boolean" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 4008b3a5c..324858da1 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -82,6 +82,55 @@ fn tool_table_is_well_formed() { } } +#[test] +fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() { + let dir = workdir("font-ranges"); + std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + let mut a = auto(&dir); + std::fs::write(dir.join("broken.otf"), b"not a font").unwrap(); + let found = ok(&mut a, "text_font_files", json!({})); + assert_eq!(found["fonts"].as_array().unwrap().len(), 1); + assert_eq!(found["fonts"][0]["font"], "Inter-Regular"); + assert!(a.call("text_font_files", &json!({"directories":["../outside"]})).is_err()); + let id = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + let font = ok(&mut a, "text_font_import", json!({"doc":id,"page":1,"path":"inter.ttf"})); + let resource = font["resource"].as_str().unwrap(); + assert_eq!(font["font"], "Inter-Regular"); + assert_eq!(page_text(&mut a, id), ["Page 1", "Page 2", "Page 3"]); + let draft = ok( + &mut a, + "text_edit_layout", + json!({"doc":id,"page":1,"paragraph":1,"text":"Aé ΩЖ","font_resource":resource,"ranges":[{"range":[0,3],"size":16,"color":"#cc0000"},{"range":[4,8],"size":20,"color":"#003399"}]}), + ); + assert!(draft["draft_rect"].is_array()); + assert_eq!(page_text(&mut a, id), ["Page 1", "Page 2", "Page 3"]); + ok( + &mut a, + "text_edit", + json!({"doc":id,"page":1,"paragraph":1,"text":"Aé ΩЖ","font_resource":resource,"ranges":[{"range":[0,3],"size":16,"color":"#cc0000"},{"range":[4,8],"size":20,"color":"#003399"}]}), + ); + let lines = ok(&mut a, "text_lines", json!({"doc":id,"page":1})); + assert!( + lines["lines"] + .as_array() + .unwrap() + .iter() + .any(|l| l["text"].as_str().is_some_and(|s| s.trim_end() == "Aé") && l["size"].as_f64() == Some(16.0)), + "{lines}" + ); + let rendered = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(); + ok(&mut a, "doc_save", json!({"doc":id,"path":"edited.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap(); + let saved = ok(&mut a, "text_lines", json!({"doc":reopened,"page":1})); + assert_eq!(lines, saved); + assert_eq!(rendered, a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap()); + let before = saved.clone(); + assert!(a.call("text_edit", &json!({"doc":reopened,"page":1,"paragraph":1,"text":"é test","ranges":[{"range":[1,2],"size":18}]})).is_err()); + assert_eq!(ok(&mut a, "text_lines", json!({"doc":reopened,"page":1})), before); + assert!(a.call("text_font_import", &json!({"doc":id,"page":1,"path":"../outside.ttf"})).is_err()); + std::fs::remove_dir_all(dir).unwrap(); +} + /// Each of these writes to its required `path` and replaces an existing file there, so the MCP /// annotations must not tell clients the call is read-only or harmless (#130). #[test] @@ -124,6 +173,30 @@ fn open_inspect_render_and_find() { assert_eq!((info.width, info.height), (200, 300)); } +#[test] +fn text_editor_background_is_read_only_and_removes_source_glyphs() { + let dir = workdir("text-preview"); + let mut a = auto(&dir); + std::fs::write(dir.join("text-preview.pdf"), fixture(2)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "text-preview.pdf" }))["doc"].as_u64().unwrap(); + let args = json!({ "doc": doc, "page": 1, "dpi": 72 }); + let before = a.call("page_render", &args).unwrap(); + let state = ok(&mut a, "doc_info", json!({ "doc": doc })); + let preview = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72, "hide_text_block": 1 })).unwrap(); + assert_ne!(preview, before); + let Content::Png { data, .. } = &preview[0] else { panic!("expected PNG") }; + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + assert!(pixels[..info.buffer_size()].iter().all(|b| *b == 255), "no source glyphs remain on the white page"); + assert_eq!(a.call("page_render", &args).unwrap(), before); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), state); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2"]); + for block in [-1, 0, 2, i64::MAX] { + assert!(a.call("page_render", &json!({ "doc": doc, "page": 1, "hide_text_block": block })).is_err()); + } +} + #[test] fn edit_undo_redo_and_save_round_trip() { let dir = workdir("edit"); @@ -4418,6 +4491,135 @@ fn text_extract_with_a_rect_takes_one_column() { } } +#[test] +fn mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; + let dir = workdir("mixed-region"); + let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&cos)[0].clone(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold")); + let bold = cos.add(Object::Dict(font)); + let mut resources = cos.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut fonts = cos.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone(); + fonts.set(b"F2".to_vec(), Object::Ref(bold)); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let stream = cos.add(Object::Stream(Stream::flate(Dict::new(), + b"BT /F2 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET BT /F1 12 Tf 0 g 20 217 Td (Body ) Tj /F2 12 Tf 0 0.2 0.5 rg (bold) Tj /F1 12 Tf 0 g ( text) Tj ET"))); + cos.update_dict(page.obj, |d| { + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(stream)); + }) + .unwrap(); + std::fs::write(dir.join("mixed.pdf"), write_incremental(&cos, &Default::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"mixed.pdf"}))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + assert_eq!(before["count"], 1); + assert_eq!(before["paragraphs"][0]["editor_text"], "Heading\nBody bold text"); + assert_eq!(before["paragraphs"][0]["rows"].as_array().unwrap().len(), 2); + assert_eq!(before["paragraphs"][0]["lines"].as_array().unwrap().len(), 4); + let unchanged = ok(&mut a, "text_edit_layout", json!({"doc":doc,"page":1,"paragraph":1})); + assert_eq!(unchanged["overflow"], false); + assert_eq!(unchanged["source_rect"], unchanged["draft_rect"]); + let growing = ok(&mut a, "text_edit_layout", json!({"doc":doc,"page":1,"paragraph":1,"text":"Heading\nBody bold text\nExtra row\nAnother row"})); + assert_eq!(growing["overflow"], true); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})), before, "measuring does not apply a draft"); + let normal = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + let preview = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":1})).unwrap(); + assert_ne!(normal, preview); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})), before, "preview is read-only"); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":"Heading\nText bold text"})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"out/mixed-edited.pdf"})); + let re = ok(&mut a, "doc_open", json!({"path":"out/mixed-edited.pdf"}))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "text_paragraphs", json!({"doc":re,"page":1})); + assert_eq!(after["count"], 1); + assert_eq!(after["paragraphs"][0]["editor_text"], "Heading\nText bold text"); + let saved = Document::open(std::sync::Arc::new(std::fs::read(dir.join("out/mixed-edited.pdf")).unwrap())).unwrap(); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("saved.pdf", None, std::sync::Arc::new(write_incremental(&saved, &Default::default()).unwrap()), None).unwrap(); + let runs = session.get(id).unwrap().text_lines(0); + let preserved = runs.iter().find(|l| l.text == "bold").unwrap(); + assert!(preserved.bold); + assert_eq!(preserved.color, [0.0, 0.2, 0.5]); + ok(&mut a, "text_edit", json!({"doc":re,"page":1,"paragraph":1,"text":""})); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":re,"page":1}))["count"], 0); + ok(&mut a, "edit_undo", json!({"doc":re})); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":re,"page":1})), after); +} + +#[test] +fn clipped_image_bounds_and_text_edit_save_reopen_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; + let dir = workdir("clipped-artwork"); + let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&cos)[0].clone(); + let mut image = Dict::new(); + for (key, value) in [ + ("Type", Object::name("XObject")), + ("Subtype", Object::name("Image")), + ("Width", Object::Int(1)), + ("Height", Object::Int(1)), + ("BitsPerComponent", Object::Int(8)), + ("ColorSpace", Object::name("DeviceRGB")), + ] { + image.set(key.as_bytes().to_vec(), value); + } + let image = cos.add(Object::Stream(Stream::flate(image, &[220, 235, 245]))); + let mut resources = cos.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(image)); + resources.set(b"XObject".to_vec(), Object::Dict(xo)); + let contents = cos.add(Object::Stream(Stream::flate( + Dict::new(), + b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 195 Td (Shared caption) Tj ET Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET", + ))); + cos.update_dict(page.obj, |d| { + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + }) + .unwrap(); + std::fs::write(dir.join("clipped.pdf"), write_incremental(&cos, &Default::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"clipped.pdf"}))["doc"].as_u64().unwrap(); + let pixels = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + let images = ok(&mut a, "page_images", json!({"doc":doc,"page":1})); + assert_eq!(ok(&mut a, "image_hit", json!({"doc":doc,"page":1,"point":[50,60]}))["images"], json!([1])); + assert_eq!(ok(&mut a, "image_hit", json!({"doc":doc,"page":1,"point":[20,60]}))["images"], json!([])); + assert_eq!(images["images"][0]["visible_rect"], json!([30.0, 50.0, 160.0, 130.0])); + assert_eq!(images["images"][0]["rect"], json!([-300.0, -200.0, 500.0, 700.0]), "raw placement remains available for transforms"); + let objects = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + let object = objects["objects"].as_array().unwrap().iter().find(|o| o["kind"] == "image").unwrap(); + assert_eq!(object["rect"], images["images"][0]["visible_rect"]); + assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), pixels, "inventory does not change rendering"); + let labels = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"move","rect":[-432.0,-300.0,688.0,960.0]})); + let resized = ok(&mut a, "page_images", json!({"doc":doc,"page":1})); + assert_eq!( + resized["images"][0]["visible_rect"], + json!([30.0, 50.0, 160.0, 130.0]), + "a clip shared with the caption stays where it is (#1049 moves only an image's own clip)" + ); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})), labels); + // The one-colour image still fills the fixed shared clip, so the page looks the same. + let resized_pixels = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"out/resized.pdf"})); + let resized_re = ok(&mut a, "doc_open", json!({"path":"out/resized.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "page_images", json!({"doc":resized_re,"page":1})), resized); + assert_eq!(a.call("page_render", &json!({"doc":resized_re,"page":1,"dpi":72})).unwrap(), resized_pixels); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), pixels); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Edited caption"})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"out/edited.pdf"})); + let re = ok(&mut a, "doc_open", json!({"path":"out/edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":re,"page":1}))["paragraphs"][1]["editor_text"], "Edited caption"); + assert_eq!(ok(&mut a, "page_images", json!({"doc":re,"page":1})), images, "image placement and crop survive text editing"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), pixels); +} + /// Independent inch/mm dimensions, checked in the saved file through the tool path. #[test] fn architectural_and_iso_papers_through_tools() { diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 0c7737672..4eb2d6132 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -15,6 +15,7 @@ pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } +kurbo = { workspace = true } # Display order of right-to-left lines in added text, and reading and writing order in Edit text; # already in the tree via the interface. unicode-bidi = "0.3" diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 134211bdd..7bb17d067 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -3,8 +3,10 @@ //! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place), //! or delete. Only the stream that draws it is rewritten, as a new object. +use kurbo::{BezPath, Point, Shape}; use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use std::sync::Arc; use crate::EditError; @@ -13,6 +15,11 @@ use crate::EditError; pub struct PageImage { /// Its box in user space. pub rect: [f64; 4], + /// Visible frame after page and analytic path bounds. Hit testing also respects curve + /// interiors, winding rules and holes, rather than intercepting their empty areas. + pub visible_rect: Option<[f64; 4]>, + /// Current page/path clip in user space. A private crop transforms with its image. + pub clip_rect: Option<[f64; 4]>, /// The placement: image unit square or Form coordinates → user space. pub matrix: [f64; 6], /// The XObject resource name and object. @@ -27,6 +34,41 @@ pub struct PageImage { op: usize, /// CTM before `Do`, excluding a Form's own Matrix. draw_matrix: [f64; 6], + clips: Vec, +} + +impl PageImage { + /// Whether a user-space point belongs to the placed artwork and every active clip. + pub fn contains(&self, point: [f64; 2]) -> bool { + if !point.iter().all(|v| v.is_finite()) { + return false; + } + let Some(bounds) = self.visible_rect else { + return false; + }; + if point[0] < bounds[0] || point[0] > bounds[2] || point[1] < bounds[1] || point[1] > bounds[3] { + return false; + } + if !self.is_form { + let Some(inverse) = Matrix(self.matrix).invert() else { + return false; + }; + let (x, y) = inverse.apply(point[0], point[1]); + if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) { + return false; + } + } + self.clips.iter().all(|clip| { + let winding = clip.path.winding(Point::new(point[0], point[1])); + if clip.even_odd { winding % 2 != 0 } else { winding != 0 } + }) + } +} + +#[derive(Clone, Debug, PartialEq)] +struct ClipPath { + path: Arc, + even_odd: bool, } fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { @@ -59,6 +101,113 @@ fn xobjects(doc: &Document, page: &Dict) -> Dict { .unwrap_or_default() } +fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> { + let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]; + (r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r) +} + +#[derive(Clone)] +struct Placement { + matrix: Matrix, + clip: [f64; 4], + clips: Vec, +} + +/// Path coordinates are transformed when constructed; q/Q save clips, not the current path. +struct ImageState { + placement: Placement, + stack: Vec, + path: BezPath, + pending_clip: Option, +} + +impl ImageState { + fn new(matrix: Matrix, clip: [f64; 4], clips: Vec) -> Self { + Self { placement: Placement { matrix, clip, clips }, stack: Vec::new(), path: BezPath::new(), pending_clip: None } + } + + fn point(&self, x: f64, y: f64) -> Point { + let (x, y) = self.placement.matrix.apply(x, y); + Point::new(x, y) + } + + fn apply(&mut self, op: &Op) -> Result<(), EditError> { + if matches!(op.op.as_slice(), b"cm" | b"m" | b"l" | b"c" | b"v" | b"y" | b"re") + && op.operands.iter().any(|v| v.as_f64().is_some_and(|v| !v.is_finite() || v.abs() > 1e12)) + { + return Err(EditError::Invalid("the image path contains invalid coordinates".into())); + } + if self.path.elements().len() > 16384 || self.stack.len() > 4096 || self.placement.clips.len() > 256 { + return Err(EditError::Invalid("the image clip or graphics state exceeds its complexity budget".into())); + } + match op.op.as_slice() { + b"q" => self.stack.push(self.placement.clone()), + b"Q" => { + if let Some(saved) = self.stack.pop() { + self.placement = saved; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + self.placement.matrix = Matrix(m).then(&self.placement.matrix); + } + } + b"m" => { + if let Some([x, y]) = op.nums::<2>() { + self.path.move_to(self.point(x, y)); + } + } + b"l" => { + if let Some([x, y]) = op.nums::<2>() { + self.path.line_to(self.point(x, y)); + } + } + b"c" => { + if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() { + self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)); + } + } + b"v" => { + if let Some([x1, y1, x2, y2]) = op.nums::<4>() + && let Some(current) = self.path.elements().last().and_then(|e| e.end_point()) + { + self.path.curve_to(current, self.point(x1, y1), self.point(x2, y2)); + } + } + b"y" => { + if let Some([x1, y1, x2, y2]) = op.nums::<4>() { + let end = self.point(x2, y2); + self.path.curve_to(self.point(x1, y1), end, end); + } + } + b"h" => self.path.close_path(), + b"re" => { + if let Some([x, y, w, h]) = op.nums::<4>() { + self.path.move_to(self.point(x, y)); + self.path.line_to(self.point(x + w, y)); + self.path.line_to(self.point(x + w, y + h)); + self.path.line_to(self.point(x, y + h)); + self.path.close_path(); + } + } + b"W" => self.pending_clip = Some(false), + b"W*" => self.pending_clip = Some(true), + b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { + if let Some(even_odd) = self.pending_clip.take() { + // Filling/clipping implicitly closes open subpaths. BezPath's winding + // follows the same rule, and its bounds use cubic extrema, not controls. + let bounds = self.path.bounding_box(); + self.placement.clip = intersection(self.placement.clip, [bounds.x0, bounds.y0, bounds.x1, bounds.y1]).unwrap_or([0.0; 4]); + self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd }); + } + self.path = BezPath::new(); + } + _ => {} + } + Ok(()) + } +} + /// The images and Form artwork page `page` (0-based) draws, in drawing order. /// Forms stay intact: no traversal or rewriting of their internal objects is needed. pub fn page_images(doc: &Document, page: usize) -> Result, EditError> { @@ -66,76 +215,70 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let xo = xobjects(doc, &p.dict); let mut out = Vec::new(); // The graphics state carries over from one stream to the next. - let mut ctm = Matrix::IDENTITY; - let mut stack = Vec::new(); + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { for (i, op) in parse(&data).ops.iter().enumerate() { - match op.op.as_slice() { - b"q" => stack.push(ctm), - b"Q" => ctm = stack.pop().unwrap_or(ctm), - b"cm" => { - if let Some(m) = op.nums::<6>() { - ctm = Matrix(m).then(&ctm); - } - } - b"Do" => { - let Some(name) = op.name(0) else { continue }; - let Some(resource) = xo.get(name) else { continue }; - let obj = doc.resolve(resource); - let Object::Stream(s) = &*obj else { continue }; - let is_form = s.dict.name(b"Subtype") == Some(b"Form"); - let (matrix, bounds) = if is_form { - let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| { - v.as_array().and_then(|a| { - if a.len() != 4 { - return None; - } - let mut b = [0.0; 4]; - for (n, v) in b.iter_mut().zip(a) { - *n = v.as_f64()?; - } - Some(b) - }) - }) else { - continue; - }; - let matrix = match s.dict.get(b"Matrix") { - Some(v) => { - let v = doc.resolve(v); - let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue }; - m + state.apply(op)?; + let ctm = state.placement.matrix; + if op.is("Do") { + let Some(name) = op.name(0) else { continue }; + let Some(resource) = xo.get(name) else { continue }; + let obj = doc.resolve(resource); + let Object::Stream(s) = &*obj else { continue }; + let is_form = s.dict.name(b"Subtype") == Some(b"Form"); + let (matrix, bounds) = if is_form { + let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| { + v.as_array().and_then(|a| { + if a.len() != 4 { + return None; } - None => Matrix::IDENTITY, - }; - (matrix.then(&ctm), bounds) - } else if s.dict.name(b"Subtype") == Some(b"Image") { - (ctm, [0.0, 0.0, 1.0, 1.0]) - } else { + let mut b = [0.0; 4]; + for (n, v) in b.iter_mut().zip(a) { + *n = v.as_f64()?; + } + Some(b) + }) + }) else { continue; }; - let rect = matrix.bbox(bounds); - if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite()) - || bounds[2] <= bounds[0] - || bounds[3] <= bounds[1] - || rect[2] <= rect[0] - || rect[3] <= rect[1] - { - continue; - } - out.push(PageImage { - rect, - matrix: matrix.0, - name: String::from_utf8_lossy(name).into_owned(), - object: resource.as_ref(), - is_form, - width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, - height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, - stream: si, - op: i, - draw_matrix: ctm.0, - }); + let matrix = match s.dict.get(b"Matrix") { + Some(v) => { + let v = doc.resolve(v); + let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue }; + m + } + None => Matrix::IDENTITY, + }; + (matrix.then(&ctm), bounds) + } else if s.dict.name(b"Subtype") == Some(b"Image") { + (ctm, [0.0, 0.0, 1.0, 1.0]) + } else { + continue; + }; + let rect = matrix.bbox(bounds); + if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite()) + || bounds[2] <= bounds[0] + || bounds[3] <= bounds[1] + || rect[2] <= rect[0] + || rect[3] <= rect[1] + { + continue; } - _ => {} + out.push(PageImage { + rect, + visible_rect: intersection(rect, state.placement.clip), + clip_rect: Some(state.placement.clip), + matrix: matrix.0, + name: String::from_utf8_lossy(name).into_owned(), + object: resource.as_ref(), + is_form, + width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, + height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, + stream: si, + op: i, + draw_matrix: ctm.0, + clips: state.placement.clips.clone(), + }); } } } @@ -153,65 +296,73 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi path: Vec, visits: usize, } - struct State { - ctm: Matrix, - stack: Vec, - } - fn walk(doc: &Document, data: &[u8], resources: &Dict, st: &mut State, stream: usize, forms: &mut Forms, out: &mut Vec) { + fn walk( + doc: &Document, + data: &[u8], + resources: &Dict, + st: &mut ImageState, + stream: usize, + forms: &mut Forms, + out: &mut Vec, + ) -> Result<(), EditError> { let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - let (ctm, stack) = (&mut st.ctm, &mut st.stack); for (i, op) in parse(data).ops.iter().enumerate() { - match op.op.as_slice() { - b"q" => stack.push(*ctm), - b"Q" => *ctm = stack.pop().unwrap_or(*ctm), - b"cm" => { - if let Some(m) = op.nums::<6>() { - *ctm = Matrix(m).then(ctm); + st.apply(op)?; + let ctm = st.placement.matrix; + if op.is("Do") { + let Some(name) = op.name(0) else { continue }; + if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.visits) { + if let Some(r) = form.obj { + forms.path.push(r); } + let bounds = + xo.get(name).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().and_then(|d| d.get(b"BBox")).map(|b| doc.resolve(b))).and_then( + |b| { + let a = b.as_array()?; + let [x0, y0, x1, y1] = a.as_slice() else { return None }; + Some(form.ctm.bbox([x0.as_f64()?, y0.as_f64()?, x1.as_f64()?, y1.as_f64()?])) + }, + ); + let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4])); + let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone()); + walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?; + if form.obj.is_some() { + forms.path.pop(); + } + continue; } - b"Do" => { - let Some(name) = op.name(0) else { continue }; - if let Some(form) = crate::text::form_call(doc, resources, name, *ctm, &forms.path, &mut forms.visits) { - if let Some(r) = form.obj { - forms.path.push(r); - } - let mut inner = State { ctm: form.ctm, stack: Vec::new() }; - walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out); - if form.obj.is_some() { - forms.path.pop(); - } - continue; - } - let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue }; - let obj = doc.get(r); - let Object::Stream(s) = &*obj else { continue }; - if s.dict.name(b"Subtype") != Some(b"Image") { - continue; - } - out.push(PageImage { - rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), - matrix: ctm.0, - name: String::from_utf8_lossy(name).into_owned(), - object: Some(r), - is_form: false, - width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32, - height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32, - stream, - op: i, - draw_matrix: ctm.0, - }); + let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.name(b"Subtype") != Some(b"Image") { + continue; } - _ => {} + out.push(PageImage { + rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), + visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip), + clip_rect: Some(st.placement.clip), + matrix: ctm.0, + name: String::from_utf8_lossy(name).into_owned(), + object: Some(r), + is_form: false, + width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32, + height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32, + stream, + op: i, + draw_matrix: ctm.0, + clips: st.placement.clips.clone(), + }); } } + Ok(()) } let p = page_of(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); - let mut st = State { ctm: Matrix::IDENTITY, stack: Vec::new() }; + let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), visits: 0 }; for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { - walk(doc, &data, &res, &mut st, si, &mut forms, &mut out); + walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?; } Ok(out) } diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index dfa4c129e..32e0eee92 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -625,7 +625,10 @@ pub mod images; pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre}; pub mod bidi; pub mod text; -pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, text_lines}; +pub use text::{ + BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, + text_lines, +}; pub mod added; pub use added::{ Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers, diff --git a/crates/edit/src/objects.rs b/crates/edit/src/objects.rs index 169d4a2b5..cdd289457 100644 --- a/crates/edit/src/objects.rs +++ b/crates/edit/src/objects.rs @@ -81,9 +81,10 @@ pub fn editable_objects(doc: &Document, page: usize) -> Result 3.0 * 12.0); + } +} + +#[test] +fn spaces_in_another_subset_do_not_split_a_line() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj /F2 12 Tf ( ) Tj /F1 12 Tf (world) Tj ET"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].text, "Hello world"); + text::replace_block(&mut doc, 0, 0, "Goodbye").unwrap(); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "Goodbye"); +} + +#[test] +fn editor_background_removes_glyphs_but_preserves_following_text_advance() { + for show in ["(Hello) Tj", "[(He) -20 (llo)] TJ", "(Hello) '", "2 1 (Hello) \""] { + let mut doc = text_page(&format!("BT /F1 12 Tf 14 TL 72 700 Td {show} [-3000] TJ /F2 12 Tf (ab) Tj ET")); + let before = text::text_lines(&doc, 0).unwrap(); + assert_eq!(before.len(), 2); + text::clear_block(&mut doc, 0, 0).unwrap(); + let after = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(after.len(), 1, "{show}"); + assert_eq!(after[0].text, "ab"); + for (a, b) in after[0].rect.iter().zip(before[1].rect) { + assert!((a - b).abs() < 1e-6, "unselected text moved: {show}"); + } + } +} + +#[test] +fn editor_background_refuses_clipping_text_without_mutation() { + let mut doc = text_page("BT /F1 12 Tf 7 Tr 72 700 Td (Clip) Tj ET"); + let before = page_content_bytes(&doc, 0); + assert!(text::clear_block(&mut doc, 0, 0).is_err()); + assert_eq!(page_content_bytes(&doc, 0), before); +} + +#[test] +fn explicit_colour_spaces_expose_the_text_colour() { + let doc = text_page("/DeviceRGB cs 0 0.23 0.45 sc BT /F1 12 Tf 72 700 Td (Blue) Tj ET"); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].color, [0.0, 0.23, 0.45]); +} + +#[test] +fn words_in_separate_text_objects_form_one_editable_paragraph() { + let mut doc = text_page( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello) Tj ET \ + BT /F1 12 Tf 1 0 0 1 103 700 Tm (world) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 685 Tm (Second) Tj ET \ + BT /F1 12 Tf 1 0 0 1 117 685 Tm (line) Tj ET \ + BT /F1 12 Tf 1 0 0 1 300 700 Tm (Other column) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].text, "Hello world Second line"); + assert_eq!(blocks[0].lines.len(), 2); + let other = blocks[1].rect; + text::replace_block(&mut doc, 0, 0, "Replacement").unwrap(); + let blocks = text::text_blocks(&reopen(&doc), 0).unwrap(); + assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["Replacement", "Other column"]); + assert_eq!(blocks[1].rect, other); +} + +#[test] +fn independent_text_objects_with_different_transforms_stay_separate() { + let doc = text_page( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello) Tj ET \ + BT /F1 12 Tf 0 1 -1 0 103 700 Tm (Vertical) Tj ET", + ); + assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2); +} + #[test] fn text_lines_are_found_and_replaced_in_place() { let mut doc = @@ -2341,13 +2424,25 @@ fn group_moves_preserve_small_offsets_under_large_transforms() { let before = editable_objects(&doc, 0).unwrap(); let targets = before.iter().filter(|o| o.text.as_deref() != Some("Neighbour")).map(|o| o.target).collect::>(); assert_eq!(targets.len(), 2); + let image_before = images::page_images(&doc, 0).unwrap()[0].rect; let offset = [0.00123456789, -0.00987654321]; move_objects(&mut doc, 0, &targets, offset).unwrap(); - let after = editable_objects(&reopen(&doc), 0).unwrap(); + let reopened = reopen(&doc); + let image_after = images::page_images(&reopened, 0).unwrap()[0].clone(); + assert!( + close(image_after.rect, [image_before[0] + offset[0], image_before[1] + offset[1], image_before[2] + offset[0], image_before[3] + offset[1]]), + "full image placement preserves sub-point movement" + ); + let after = editable_objects(&reopened, 0).unwrap(); for old in before { let new = after.iter().find(|o| o.target == old.target).unwrap(); let [dx, dy] = if targets.contains(&old.target) { offset } else { [0.0, 0.0] }; - assert!(close(new.rect, [old.rect[0] + dx, old.rect[1] + dy, old.rect[2] + dx, old.rect[3] + dy]), "{old:?}: {new:?}"); + if old.target.kind == crate::ObjectKind::Image { + // The full placement moves; the visible right/top edges stay at the page crop. + assert_eq!(Some(new.rect), image_after.visible_rect); + } else { + assert!(close(new.rect, [old.rect[0] + dx, old.rect[1] + dy, old.rect[2] + dx, old.rect[3] + dy]), "{old:?}: {new:?}"); + } } } @@ -2657,3 +2752,319 @@ fn group_move_budget_refusals_keep_the_original_page_selectable() { assert_eq!(editable_objects(&doc, 0).unwrap(), objects, "refusal retains a usable inventory"); } } + +fn mixed_region_page() -> Document { + let mut doc = text_page( + "BT /F2 1 Tf 18 0 0 18 123 700 Tm (TITLE) Tj ET \ + BT /F2 1 Tf 12 0 0 12 117.6 676 Tm (two lines) Tj ET \ + BT /F2 1 Tf 12 0 0 12 135.6 652 Tm (last) Tj ET \ + BT 0 0.2 0.5 rg /F2 10 Tf 72 600 Td (About) Tj ET \ + BT 0 g /F1 10 Tf 72 582 Td (Plain ) Tj /F2 10 Tf 0 0.2 0.5 rg (bold) Tj /F1 10 Tf 0 g ( text.) Tj ET \ + BT 0.2 0.4 0.7 rg /F3 8 Tf 72 566 Td (Italic footer) Tj ET \ + BT 0 g /F1 10 Tf 400 600 Td (Other column) Tj ET", + ); + for (obj, base) in [(5, "Courier"), (6, "Courier-Bold")] { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + doc.set(pdfcraft_cos::ObjRef::new(obj, 0), Object::Dict(f)); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name("Courier-Oblique")); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let font = doc.add(Object::Dict(f)); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone(); + fonts.set(b"F3".to_vec(), Object::Ref(font)); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + doc +} + +#[test] +fn mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns() { + let doc = mixed_region_page(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 3); + assert_eq!(blocks[0].editor_text, "TITLE\ntwo lines\nlast"); + assert_eq!(blocks[0].rows.len(), 3); + assert_eq!(blocks[1].editor_text, "About\nPlain bold text.\nItalic footer"); + assert_eq!(blocks[1].rows.len(), 3); + assert_eq!(blocks[1].lines.len(), 5); + assert_eq!(blocks[2].text, "Other column"); +} + +#[test] +fn explicit_editor_rows_survive_single_run_edit_and_reopen() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original heading) Tj ET"); + let text = "First row\nSecond row\nLast row"; + let layout = text::text_edit_layout(&doc, 0, 0, text, &text::BlockStyle::default()).unwrap(); + text::replace_block(&mut doc, 0, 0, text).unwrap(); + let blocks = text::text_blocks(&reopen(&doc), 0).unwrap(); + assert_eq!(blocks[0].editor_text, text); + assert_eq!(blocks[0].rows.len(), 3); + assert_eq!(Some(blocks[0].rect), layout.draft_rect); +} + +#[test] +fn rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen() { + let original = mixed_region_page(); + let before = text::text_lines(&original, 0).unwrap(); + let block = text::text_blocks(&original, 0).unwrap()[1].clone(); + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, 1, &block.editor_text.replace("Plain", "Other")).unwrap(); + let reopened = reopen(&doc); + let blocks = text::text_blocks(&reopened, 0).unwrap(); + assert_eq!(blocks.len(), 3); + assert_eq!(blocks[1].editor_text, "About\nOther bold text.\nItalic footer"); + let lines = text::text_lines(&reopened, 0).unwrap(); + for word in ["About", "bold", "Italic footer"] { + let source = before.iter().find(|l| l.text == word).unwrap(); + let after = lines.iter().find(|l| l.text == word).unwrap(); + assert_eq!( + (after.base_font.as_str(), after.size, after.color, after.bold, after.italic), + (source.base_font.as_str(), source.size, source.color, source.bold, source.italic) + ); + assert!((after.origin_baseline() - source.origin_baseline()).abs() < 0.001); + } + for i in [0, 2] { + assert_eq!(blocks[i].editor_text, text::text_blocks(&original, 0).unwrap()[i].editor_text); + assert_eq!(blocks[i].rect, text::text_blocks(&original, 0).unwrap()[i].rect); + } + let mut removed = original.clone(); + text::replace_block(&mut removed, 0, 1, "").unwrap(); + let after = text::text_blocks(&reopen(&removed), 0).unwrap(); + assert_eq!(after.len(), 2); + assert_eq!(after[0].editor_text, "TITLE\ntwo lines\nlast"); + assert_eq!(after[1].text, "Other column"); + let mut preview = original.clone(); + text::clear_block(&mut preview, 0, 1).unwrap(); + assert_eq!(text::text_blocks(&reopen(&preview), 0).unwrap().len(), 2); + assert_eq!(text::text_blocks(&original, 0).unwrap().len(), 3); +} + +#[test] +fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() { + let mut doc = mixed_region_page(); + let block = text::text_blocks(&doc, 0).unwrap()[1].clone(); + let a = block.editor_text.find("Plain").unwrap(); + let b = block.editor_text.find("bold").unwrap(); + let style = BlockStyle { + ranges: vec![ + RangeStyle { + range: a..a + 5, + family: None, + resource: None, + size: Some(15.0), + color: Some([0.8, 0.1, 0.2]), + underline: Some(true), + char_spacing: None, + scale: None, + }, + RangeStyle { + range: b..b + 4, + family: Some((Family::Courier, false, true)), + resource: None, + size: None, + color: None, + underline: None, + char_spacing: Some(0.2), + scale: Some(90.0), + }, + ], + width: Some(300.0), + ..Default::default() + }; + text::rewrite_block(&mut doc, 0, 1, Some(&block.editor_text), &style).unwrap(); + doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let plain = lines.iter().find(|l| l.text == "Plain").unwrap(); + assert_eq!((plain.size, plain.color), (15.0, [0.8, 0.1, 0.2])); + let bold = lines.iter().find(|l| l.text == "bold").unwrap(); + assert_eq!(bold.base_font, "Courier-Oblique"); + let original = mixed_region_page(); + for word in ["About", "Italic footer"] { + let prior = text::text_lines(&original, 0).unwrap().into_iter().find(|l| l.text == word).unwrap(); + let after = lines.iter().find(|l| l.text == word).unwrap(); + assert_eq!((after.size, after.color, &after.font, after.baseline()), (prior.size, prior.color, &prior.font, prior.baseline())); + } + let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + let invalid = BlockStyle { + ranges: vec![RangeStyle { + range: 1..2, + family: None, + resource: None, + size: Some(12.0), + color: None, + underline: None, + char_spacing: None, + scale: None, + }], + ..Default::default() + }; + assert!(text::rewrite_block(&mut doc, 0, 1, Some("é bad"), &invalid).is_err()); + assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before); +} + +#[test] +fn centered_mixed_size_title_keeps_three_rows_when_changed() { + let mut doc = mixed_region_page(); + let title = text::text_blocks(&doc, 0).unwrap()[0].clone(); + text::replace_block(&mut doc, 0, 0, &title.editor_text.replace("last", "tail")).unwrap(); + let reopened = reopen(&doc); + let blocks = text::text_blocks(&reopened, 0).unwrap(); + assert_eq!(blocks[0].editor_text, "TITLE\ntwo lines\ntail"); + assert_eq!(blocks[0].rows.len(), 3); + let lines = text::text_lines(&reopened, 0).unwrap(); + for row in &blocks[0].rows { + let line = &lines[row[0]]; + assert!(((line.rect[0] + line.rect[2]) / 2.0 - 150.0).abs() < 0.01); + } +} + +#[test] +fn image_selection_bounds_respect_clips_and_restore_graphics_state() { + let mut doc = image_page(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let content = b"q 1 0 0 1 10 20 cm 20 30 100 80 re W* n q 1200 0 0 1400 -500 -600 cm /Im0 Do Q Q q 200 0 0 100 100 100 cm /Im0 Do Q q 0 0 10 10 re W n 100 0 0 100 200 200 cm /Im0 Do Q"; + let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap(); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 3, "fully clipped placements retain source indexes"); + assert_eq!(before[0].visible_rect, Some([30.0, 50.0, 130.0, 130.0])); + assert_eq!(before[1].visible_rect, Some(before[1].rect), "Q restores the prior clip"); + assert_eq!(before[2].visible_rect, None); + let objects = crate::editable_objects(&doc, 0).unwrap(); + assert_eq!(objects.len(), 2, "hidden images cannot intercept selection"); + assert_eq!(objects[0].rect, before[0].visible_rect.unwrap()); + let offset = [12.0, 14.0]; + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(pdfcraft_content::Matrix::translate(offset[0], offset[1]).0)).unwrap(); + let reopened = reopen(&doc); + let after = images::page_images(&reopened, 0).unwrap(); + assert_eq!( + after[0].visible_rect, + Some([30.0, 50.0, 130.0, 130.0]), + "a clip outside the image's own `q re W n cm Do Q` stays where it is (#1049)" + ); + for (a, b) in after[0].rect.iter().zip(before[0].rect.iter()).enumerate() { + assert!((b.0 - b.1 - offset[a % 2]).abs() < 0.001); + } + assert_eq!(after[1].rect, before[1].rect, "neighbor placement unchanged"); + assert_eq!(after[1].visible_rect, before[1].visible_rect); + assert_eq!(after[1].matrix, before[1].matrix); + assert_eq!(after[1].object, before[1].object); +} + +#[test] +fn image_clip_path_and_graphics_state_continue_across_content_streams() { + let mut doc = image_page(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let pieces: [&[u8]; 3] = [b"q 20 30 40 50 re W", b"n 1000 0 0 1000 -300 -400 cm /Im0 Do", b"Q q 50 0 0 50 100 200 cm /Im0 Do Q"]; + let refs: Vec<_> = pieces.into_iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect(); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap(); + let images = images::page_images(&doc, 0).unwrap(); + assert_eq!(images[0].visible_rect, Some([20.0, 30.0, 60.0, 80.0])); + assert_eq!(images[1].visible_rect, Some([100.0, 200.0, 150.0, 250.0])); + images::change_image( + &mut doc, + 0, + 0, + &images::ImageChange::Transform(images::rect_to_rect(images[0].visible_rect.unwrap(), [20.0, 30.0, 80.0, 105.0])), + ) + .unwrap(); + let after = images::page_images(&reopen(&doc), 0).unwrap(); + assert!(close(after[0].visible_rect.unwrap(), [20.0, 30.0, 60.0, 80.0]), "a clip split across streams stays where it is"); + assert_eq!(after[1].matrix, images[1].matrix, "graphics state restored across stream boundaries"); +} + +#[test] +fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() { + let mut doc = image_page(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let stream = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET", + ))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap(); + for target in [[30.0, 170.0, 190.0, 270.0], [30.0, 170.0, 110.0, 220.0], [70.0, 210.0, 150.0, 260.0]] { + let before = images::page_images(&doc, 0).unwrap()[0].clone(); + let transform = images::rect_to_rect(before.visible_rect.unwrap(), target); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap(); + doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap()[0].clone(); + // The moved clip's `cm` is written with the content stream's number precision. + let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05); + assert!(near(after.visible_rect.unwrap(), target), "{:?} vs {target:?}", after.visible_rect); + assert!(near(after.rect, pdfcraft_content::Matrix(transform).bbox(before.rect)), "full pixels use the same crop transform"); + assert_eq!(after.object, before.object, "pixels are never resampled or replaced"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].baseline(), [30.0, 130.0]); + } + let page = pdfcraft_model::pages(&doc)[0].clone(); + let stream = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"q 30 170 130 80 re W n q 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 40 200 Td (Shared caption) Tj ET Q", + ))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap(); + let before = images::page_images(&doc, 0).unwrap(); + let caption = text::text_lines(&doc, 0).unwrap(); + let transform = [1.2, 0.0, 0.0, 1.2, 0.0, 0.0]; + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap(); + doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert!(close(after[0].visible_rect.unwrap(), before[0].visible_rect.unwrap()), "a shared clip stays where it is"); + assert_eq!(after[0].object, before[0].object); + assert_eq!( + text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>(), + caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>() + ); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform([0.5, 0.0, 0.0, 0.5, 0.0, 0.0])).unwrap(); + assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1); +} + +#[test] +fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() { + let mut doc = image_page(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let bytes = + b"q 0 0 m 0 200 100 200 100 0 c h W n 300 0 0 300 0 0 cm /Im0 Do Q q 100 100 100 100 re 125 125 50 50 re W* n 400 0 0 400 0 0 cm /Im0 Do Q"; + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap(); + let images = images::page_images(&doc, 0).unwrap(); + assert!(close(images[0].visible_rect.unwrap(), [0.0, 0.0, 100.0, 150.0]), "cubic extrema, not controls"); + assert!(images[0].contains([50.0, 100.0])); + assert!(!images[0].contains([2.0, 140.0]), "empty corners of curve bounds are not artwork"); + assert!(images[1].contains([110.0, 110.0])); + assert!(!images[1].contains([150.0, 150.0]), "even-odd hole"); + images::change_image(&mut doc, 0, 1, &images::ImageChange::Transform([1.2, 0.0, 0.0, 1.2, 10.0, 15.0])).unwrap(); + let after = images::page_images(&reopen(&doc), 0).unwrap(); + // Only a rectangle in the image's own `q re W n cm Do Q` moves with it (#1049): these stay. + assert!(!after[1].contains([150.0, 150.0]), "the even-odd hole stays where it is"); + assert!(after[1].contains([110.0, 110.0])); +} + +#[test] +fn marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite() { + let mut doc = + text_page("/P << /MCID 0 >> BDC BT /F1 12 Tf 40 500 Td (One) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf 40 482 Td (Two) Tj ET EMC"); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2, "distinct explicit paragraphs win over nearby geometry"); + let page = pdfcraft_model::pages(&doc)[0].obj; + let parts: [&[u8]; 2] = + [b"/P << /MCID 2 >> BDC BT /F1 12 Tf 40 500 Td (First row) Tj ET", b"q 1 0 0 1 10 12 cm BT /F1 12 Tf 30 470 Td (Second row) Tj ET Q EMC"]; + let refs = parts.iter().map(|p| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), p))))).collect(); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap(); + let block = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(block.len(), 1, "one marked paragraph across streams and coordinate spaces"); + assert_eq!(block[0].editor_text, "First row\nSecond row"); + let original = text::text_lines(&doc, 0).unwrap(); + text::rewrite_block(&mut doc, 0, 0, Some("First row\nChanged row"), &text::BlockStyle { width: Some(200.0), ..Default::default() }).unwrap(); + let after = text::text_lines(&reopen(&doc), 0).unwrap(); + for (a, b) in after.iter().zip(original) { + assert!(close(a.rect, b.rect) || a.baseline() == b.baseline()); + } + assert_eq!(after[1].text, "Changed row"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 3a0d4a101..21a8c2fa4 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1,6 +1,6 @@ //! Edit a PDF ▸ Edit text: the text lines already on a page, and replacing a line's text in place. //! -//! A *line* is a run of text-showing operators inside one `BT … ET` on the same baseline, read +//! A *line* is a run of compatible text-showing operators on the same baseline, read //! from one content stream. Replacing it rewrites only that stream (as a new object, since content //! streams may be shared): the first operator of the line shows the new text, the others are //! dropped, and every positioning operator stays, so the lines after it keep their places @@ -20,6 +20,10 @@ use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; use crate::EditError; +#[path = "text_regions.rs"] +mod regions; +use regions::{group_blocks, rewrite_styled_block}; + /// One line of existing text. #[derive(Clone, Debug, PartialEq)] pub struct TextLine { @@ -46,6 +50,118 @@ pub struct TextLine { ops: Vec, /// Where the line starts: text matrix (text space), the state there, and its `BT`. origin: Origin, + /// Explicit marked-content scope (when present); distinct scopes must not be guessed into one paragraph. + scope: Option, +} + +impl TextLine { + /// Origin of the source baseline in PDF user space, independent of font ascent/descent. + pub fn baseline(&self) -> [f64; 2] { + [self.origin.x, self.origin.baseline] + } + + /// Leading used when a single source run wraps into additional rows. + pub fn wrap_leading(&self) -> f64 { + if self.origin.state.leading > 0.0 { self.origin.state.leading * self.origin.k } else { self.size * 1.2 } + } +} + +/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and +/// are never installed, copied into assets, or used as a font for another document. +#[derive(Clone, Debug)] +pub struct TextEditorFont { + pub resource: String, + pub face: String, + pub bytes: Vec, +} + +pub fn text_editor_fonts(doc: &Document, page: usize) -> Result, EditError> { + let p = page_dict(doc, page)?; + let resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default(); + let fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default(); + let mut budget = 32_usize << 20; + let mut result = Vec::new(); + for (name, font) in fonts.iter().take(128) { + let Some(font) = doc.dict(font) else { continue }; + let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) else { continue }; + if bytes.len() > budget { + continue; + } + budget -= bytes.len(); + result.push(TextEditorFont { + resource: String::from_utf8_lossy(name).into_owned(), + face: Metrics::from_dict(doc, &font).base_font.rsplit('+').next().unwrap_or_default().to_owned(), + bytes, + }); + } + // The approved full face follows all source subsets in the editor's font stack. It can + // display newly inserted glyphs before Apply without changing the source text's face. + let source_faces: Vec<_> = result.iter().map(|p| p.face.clone()).collect(); + for face in source_faces { + for full in pdfcraft_fonts::embedding::matching_faces(&face) { + if full.bytes.len() > budget || result.iter().any(|p| p.resource == format!("PCApproved{}", full.name())) { + continue; + } + budget -= full.bytes.len(); + result.push(TextEditorFont { resource: format!("PCApproved{}", full.name()), face: face.clone(), bytes: full.bytes.to_vec() }); + } + } + Ok(result) +} + +/// Load an explicit full face into this page's resources. Nothing on the page is replaced; +/// the new resource can complete matching source subsets or be chosen in a text style. +pub fn register_text_font(doc: &mut Document, page: usize, bytes: &[u8]) -> Result { + pdfcraft_fonts::embedding::validate_document_input(bytes).map_err(EditError::Invalid)?; + let (_, postscript) = pdfcraft_fonts::embedding::names(bytes).map_err(EditError::Invalid)?; + let p = page_dict(doc, page)?; + let mut resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default(); + let mut fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default(); + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, bytes, &postscript).map_err(EditError::Invalid)?; + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources)))?; + Ok(name) +} + +/// Read-only draft bounds, measured with the same PDF rewrite used by Apply/Save. +#[derive(Clone, Debug, PartialEq)] +pub struct TextEditLayout { + pub source_rect: [f64; 4], + pub draft_rect: Option<[f64; 4]>, + pub overflow: bool, +} + +pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result { + let original = text_blocks(doc, page)?; + let source = original.get(block).ok_or(EditError::Invalid("text block does not exist".into()))?; + if text == source.editor_text && *style == BlockStyle::default() { + return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false }); + } + // Subtract the untouched runs by value, rather than by operator offsets: a rewrite may + // split BT/ET or add font resources. A multiset also handles coincident repeated text. + let key = + |line: &TextLine| (line.text.clone(), line.font.clone(), line.rect.map(f64::to_bits), line.size.to_bits(), line.color.map(f64::to_bits)); + let mut untouched = std::collections::BTreeMap::new(); + for (index, line) in text_lines(doc, page)?.iter().enumerate() { + if !source.lines.contains(&index) { + *untouched.entry(key(line)).or_insert(0_usize) += 1; + } + } + let mut draft = doc.clone(); + rewrite_block(&mut draft, page, block, Some(text), style)?; + let mut rect: Option<[f64; 4]> = None; + for line in text_lines(&draft, page)? { + if let Some(count) = untouched.get_mut(&key(&line)) + && *count > 0 + { + *count -= 1; + continue; + } + rect = Some(rect.map_or(line.rect, |r| [r[0].min(line.rect[0]), r[1].min(line.rect[1]), r[2].max(line.rect[2]), r[3].max(line.rect[3])])); + } + let overflow = rect + .is_some_and(|r| r[0] < source.rect[0] - 0.5 || r[1] < source.rect[1] - 0.5 || r[2] > source.rect[2] + 0.5 || r[3] > source.rect[3] + 0.5); + Ok(TextEditLayout { source_rect: source.rect, draft_rect: rect, overflow }) } /// Text and graphics state captured for rewriting. @@ -106,8 +222,8 @@ struct Origin { bt_state: TextState, } -/// A paragraph: consecutive lines in one stream with the same font and size, aligned on the left -/// and regularly spaced. +/// An editable text region: adjacent runs on visual rows, followed by aligned rows. +/// A region may contain several source fonts, sizes, colours and weights. #[derive(Clone, Debug, PartialEq)] pub struct TextBlock { /// The lines' text joined with spaces (lines that ended in a hyphen are joined directly). @@ -122,6 +238,54 @@ pub struct TextBlock { pub color: [f64; 3], /// Indexes into [`text_lines`]. pub lines: Vec, + /// Visual rows; each contains indexes into [`text_lines`], including mixed-style runs. + pub rows: Vec>, + /// Text for the inline editor, with a newline only between visual rows. + pub editor_text: String, + /// Byte ranges in `editor_text` and their source line indexes. + pub spans: Vec<(std::ops::Range, usize)>, +} + +impl TextBlock { + /// Preserve styles outside the changed range; inserted characters inherit the surrounding + /// source run. Byte ranges are always UTF-8 boundaries. Linear time, including large edits. + pub fn edited_spans(&self, draft: &str) -> Vec<(std::ops::Range, usize)> { + if self.editor_text.len() > 4_000_000 || draft.len() > 4_000_000 { + return vec![(0..draft.len(), self.lines.first().copied().unwrap_or(0))]; + } + let old: Vec<_> = self.editor_text.chars().collect(); + let new: Vec<_> = draft.char_indices().collect(); + let prefix = old.iter().zip(&new).take_while(|(a, (_, b))| *a == b).count(); + let suffix = + old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, (_, b))| *a == b).count(); + let mut original = Vec::with_capacity(old.len()); + let mut span = 0; + for (byte, _) in self.editor_text.char_indices() { + while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) { + span += 1; + } + original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i)); + } + let inherited = original.get(prefix.saturating_sub(1)).or_else(|| original.get(prefix)).copied().unwrap_or(0); + let mut result: Vec<(std::ops::Range, usize)> = Vec::new(); + for (i, (byte, ch)) in new.iter().enumerate() { + let source = if i < prefix { + original.get(i).copied() + } else if i >= new.len().saturating_sub(suffix) { + original.get(old.len().saturating_sub(new.len() - i)).copied() + } else { + Some(inherited) + } + .unwrap_or(inherited); + let end = byte.saturating_add(ch.len_utf8()); + if let Some((range, _)) = result.last_mut().filter(|(_, prev)| *prev == source) { + range.end = end; + } else { + result.push((*byte..end, source)); + } + } + result + } } impl TextLine { @@ -236,10 +400,19 @@ fn page_dict(doc: &Document, page: usize) -> Result [f64; 3] { - let Some(op) = fill.iter().rev().find(|op| matches!(op.op.as_slice(), b"g" | b"rg" | b"k")) else { + let Some(op) = fill.iter().rev().find(|op| matches!(op.op.as_slice(), b"g" | b"rg" | b"k" | b"sc" | b"scn")) else { return [0.0, 0.0, 0.0]; }; - match op.op.as_slice() { + let space = match op.op.as_slice() { + b"sc" | b"scn" => match op.operands.len() { + 1 => b"g".as_slice(), + 3 => b"rg".as_slice(), + 4 => b"k".as_slice(), + _ => return [0.0; 3], + }, + other => other, + }; + match space { b"g" => { let v = op.operands.first().and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0); [v, v, v] @@ -263,6 +436,9 @@ fn fill_color(fill: &[Op]) -> [f64; 3] { /// The text-showing operators of one stream with their text, font, box and baseline. struct Shown { op: usize, + extra_ops: Vec, + /// Advance in text space, including spacing and TJ adjustments. + advance: f64, /// The text matrix where it starts (text space), and the state there and at its `BT`. tm: Matrix, tlm: Matrix, @@ -282,6 +458,7 @@ struct Shown { bold: bool, italic: bool, decodable: bool, + scope: Option, } /// The graphics state carried from one of a page's content streams to the next: the streams @@ -290,6 +467,7 @@ struct Shown { struct Carry { ts: Ts, stack: Vec, + marked: Vec>, } impl Carry { @@ -307,7 +485,7 @@ impl Carry { rise: 0.0, render_mode: 0, }; - Carry { ts, stack: Vec::new() } + Carry { ts, stack: Vec::new(), marked: Vec::new() } } } @@ -321,15 +499,56 @@ fn interpret( cache: &mut HashMap, (Rc, Option)>, carry: &mut Carry, mut invoked: Option<&mut Vec>, + logical_text: bool, ) -> Vec { - let mut out = Vec::new(); + let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); let mut stack: Vec = std::mem::take(&mut carry.stack); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; + let mut marked = std::mem::take(&mut carry.marked); + let mut actual: Vec> = Vec::new(); for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { + b"BMC" | b"BDC" => { + if marked.len() < 256 { + // Marked text paragraphs/headings are authoritative boundaries. Ignore + // artifacts and inner spans, which inherit their enclosing region. + let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); + marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); + let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s { + Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), + _ => None, + }); + actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None }); + } + } + b"EMC" => { + marked.pop(); + if let Some(Some((start, open, logical))) = actual.pop() { + let tail = out.split_off(start.min(out.len())); + let mut items = tail.into_iter(); + if let Some(mut first) = items.next() { + for shown in items { + first.extra_ops.push(shown.op); + first.extra_ops.extend(shown.extra_ops); + first.rect = [ + first.rect[0].min(shown.rect[0]), + first.rect[1].min(shown.rect[1]), + first.rect[2].max(shown.rect[2]), + first.rect[3].max(shown.rect[3]), + ]; + } + first.extra_ops.extend([open, i]); + first.text = logical; + first.start_x = first.rect[0]; + first.end_x = first.rect[2]; + first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); + out.push(first); + } + } + } b"q" => stack.push(ts.clone()), b"Q" => { if let Some(s) = stack.pop() { @@ -459,6 +678,8 @@ fn interpret( } out.push(Shown { op: i, + extra_ops: Vec::new(), + advance: x_text, tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]), state: ts.clone(), tlm, @@ -477,6 +698,7 @@ fn interpret( bold: m.bold, italic: m.italic, decodable, + scope: marked.last().copied().flatten(), }); } _ => {} @@ -484,6 +706,7 @@ fn interpret( } carry.ts = ts; carry.stack = stack; + carry.marked = marked; out } @@ -499,22 +722,24 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro // joined, so an operator whose operands end one piece still has them. let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None); + let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true); let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size) for s in shown { - // A line belongs to the piece its first operator starts in (lines in different pieces - // aren't joined or grouped, as before; editing rewrites every piece an edit touches). + // A line belongs to one stream piece. Within it, PDF producers often put every word + // in its own BT/ET: geometry and text state, not object boundaries, define a line. let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0); let joins = last.is_some_and(|(piece, bt, base, end, size)| { piece == si - && bt == s.bt - && lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref())) - && lines.last().is_some_and(|l| { - (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color - }) + && lines.last().is_some_and(|l| l.scope == s.scope) + && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) + && (s.text.trim().is_empty() + || lines.last().is_some_and(|l| { + (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color + })) && (s.baseline - base).abs() < size * 0.3 && s.start_x > end - size - && s.start_x - end < size * 3.0 + && s.start_x - end < size * if bt == s.bt { 3.0 } else { 1.5 } }); if joins && let Some(l) = lines.last_mut() { let gap = s.start_x - last.map_or(s.start_x, |x| x.3); @@ -524,8 +749,9 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; l.ops.push(s.op); + l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; - l.color = fill_color(&s.state.fill); + // Spaces may use a separate subset font; they do not change the visible style. } else { lines.push(TextLine { text: s.text.clone(), @@ -539,7 +765,8 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro decodable: s.decodable, face: s.face.clone(), stream: si, - ops: vec![s.op], + scope: s.scope, + ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, tlm: s.tlm.0, @@ -564,18 +791,33 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro Ok(lines) } +/// Joining independent text objects is safe only when they use the same text and graphics +/// transforms and spacing. In particular, a nearby rotated label isn't part of this line. +fn compatible_word(line: &TextLine, shown: &Shown) -> bool { + let same = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6); + let (a, b) = (&line.origin.state, TextState::of(&shown.state)); + line.scope == shown.scope + && same(&line.origin.tm[..4], &shown.tm.0[..4]) + && same(&line.origin.ctm, &shown.state.ctm.0) + && same(&[a.char_spacing, a.word_spacing, a.scale, a.rise], &[b.char_spacing, b.word_spacing, b.scale, b.rise]) + && a.fill.len() == b.fill.len() + && a.fill.iter().zip(&b.fill).all(|(a, b)| a.op == b.op && a.operands == b.operands) +} + /// The line `s` continues, or a new one: `last` is the previous run of the same stream /// (`bt`, baseline, end x, size). fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64)>, s: Shown, stream: usize) { let joins = last.is_some_and(|(bt, base, end, size)| { - bt == s.bt - && lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref())) - && lines - .last() - .is_some_and(|l| (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color) + lines.last().is_some_and(|l| l.scope == s.scope) + && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) + && (s.text.trim().is_empty() + || lines.last().is_some_and(|l| { + (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color + })) && (s.baseline - base).abs() < size * 0.3 && s.start_x > end - size - && s.start_x - end < size * 3.0 + && s.start_x - end < size * if bt == s.bt { 3.0 } else { 1.5 } }); if joins && let Some(l) = lines.last_mut() { let gap = s.start_x - last.map_or(s.start_x, |x| x.2); @@ -585,8 +827,8 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; l.ops.push(s.op); + l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; - l.color = fill_color(&s.state.fill); } else { lines.push(TextLine { text: s.text.clone(), @@ -600,7 +842,8 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) decodable: s.decodable, face: s.face.clone(), stream, - ops: vec![s.op], + scope: s.scope, + ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, tlm: s.tlm.0, @@ -689,7 +932,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut invoked = Vec::new(); - let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked)); + let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true); let mut last = None; let mut forms = invoked.into_iter().peekable(); for s in shown { @@ -708,7 +951,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return }; let ops = parse(&form.data).ops; // The form starts with the graphics state at its `Do`, its own matrix applied. - let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new() }; + let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new() }; let stream = w.next_stream; w.next_stream = w.next_stream.saturating_add(1); if let Some(r) = form.obj { @@ -1030,6 +1273,69 @@ fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option bool { + text.split(' ').all(|word| metrics.encode(word).is_some()) +} + +fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 { + let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::()); + if let Some(w) = width(metrics) { + return w; + } + let family = metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font); + for (_, font) in fonts.iter() { + if let Some(dict) = doc.resolve(font).as_dict() { + let m = Metrics::from_dict(doc, dict); + if m.base_font.rsplit('+').next() == Some(family) + && let Some(w) = width(&m) + { + return w; + } + } + } + 0.33 +} + +fn source_width(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> f64 { + if let Some(bytes) = metrics.encode(text) { + // PDF word spacing applies only to single-byte code 32, even for a Unicode space. + return metrics + .codes(&bytes) + .iter() + .map(|(c, len)| metrics.width(*c) * size + char_spacing + if metrics.is_space(*c, *len) { word_spacing } else { 0.0 }) + .sum(); + } + let words: f64 = text + .split(' ') + .filter_map(|w| metrics.encode(w)) + .map(|b| metrics.codes(&b).iter().map(|(c, _)| metrics.width(*c) * size + char_spacing).sum::()) + .sum(); + words + text.matches(' ').count() as f64 * (space * size + char_spacing + word_spacing) +} + +fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> Result, EditError> { + if let Some(bytes) = metrics.encode(text) { + return Ok(vec![Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])]); + } + let adjustment = -1000.0 * (space + (char_spacing + word_spacing) / size); + if !adjustment.is_finite() { + return Err(EditError::Invalid("the source font's space advance is invalid".into())); + } + let mut out = Vec::new(); + for (i, word) in text.split(' ').enumerate() { + if i > 0 { + out.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); + } + if !word.is_empty() { + let bytes = metrics.encode(word).ok_or_else(|| EditError::Invalid("the source font cannot show the replacement text".into()))?; + out.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + } + Ok(out) +} + /// Replace the text of line `line` (an index into [`text_lines`]) on `page` with `text`. pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> Result { replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS) @@ -1051,7 +1357,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let font = fonts_res.get(target.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); // Typed in logical order; drawn left to right (right-to-left runs reversed). let shown = crate::bidi::to_visual(&text); - let reused = font.as_ref().and_then(|m| m.encode(&shown)); + let reused = font.as_ref().filter(|m| source_encodes(m, &shown)); let mut substituted = None; let mut replacement: Vec = Vec::new(); // ' and " also move to the next line; keep that. @@ -1065,7 +1371,14 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi _ => {} } match reused { - Some(bytes) => replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])), + Some(metrics) => replacement.extend(source_show( + metrics, + &shown, + target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size), + source_space(doc, &fonts_res, metrics), + target.origin.state.char_spacing, + target.origin.state.word_spacing, + )?), None => { if needs_type3(&text) { let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold, max_glyphs)?; @@ -1153,71 +1466,58 @@ pub fn text_blocks(doc: &Document, page: usize) -> Result, EditEr Ok(group_blocks(&lines)) } +/// Remove a paragraph's glyphs for a temporary editor background. Numeric TJ adjustments +/// preserve the text matrix, so unselected text positioned relative to it stays in place. +/// Call this on a clone: it is a preview operation, not a document edit. +pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), EditError> { + let lines = text_lines(doc, page)?; + let blocks = group_blocks(&lines); + let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?; + let rects: Vec<_> = target.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).collect(); + let mut drops = coincident_ops(&lines, &rects); + drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); + let p = page_dict(doc, page)?; + let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let streams = content_streams(doc, &p.dict); + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false); + let mut patches = HashMap::new(); + for s in shown.into_iter().filter(|s| drops.contains(&s.op)) { + if s.state.render_mode >= 4 { + return Err(EditError::Invalid("text used as a clipping path cannot be previewed for editing".into())); + } + let denominator = s.state.size * s.state.scale; + let adjustment = if denominator != 0.0 { -1000.0 * s.advance / denominator } else { 0.0 }; + if !adjustment.is_finite() || (denominator == 0.0 && s.advance != 0.0) { + return Err(EditError::Invalid("the paragraph's text advance cannot be previewed".into())); + } + let mut replacement = Vec::new(); + if let Some(op) = ops.get(s.op) { + if op.is("\"") { + replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))])); + replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))])); + } + if op.is("'") || op.is("\"") { + replacement.push(Op::new("T*", vec![])); + } + } + replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); + patches.insert(s.op, replacement); + } + let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false))); + let contents = rewritten_contents(doc, &streams, data); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + /// The paragraphs on a page as a reader sees them, including text drawn by form XObjects /// (Export to Word, HTML and RTF). Read-only: indexes into this list are not /// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`]. pub fn reading_blocks(doc: &Document, page: usize) -> Result, EditError> { let lines = reading_lines(doc, page)?; - Ok(group_blocks(&lines)) -} - -/// Lines that share a left edge, a centre or a right edge (left, centred or right-aligned text). -fn aligned(a: &TextLine, b: &TextLine) -> bool { - let tol = b.size.max(1.0); - let centre = |r: [f64; 4]| (r[0] + r[2]) / 2.0; - (a.origin.x - b.origin.x).abs() < tol || (centre(a.rect) - centre(b.rect)).abs() < tol || (a.rect[2] - b.rect[2]).abs() < tol -} - -fn group_blocks(lines: &[TextLine]) -> Vec { - let mut blocks: Vec = Vec::new(); - let mut gap: Option = None; - for (i, l) in lines.iter().enumerate() { - let joins = i > 0 - && blocks.last().is_some_and(|b| { - let Some(&p) = b.lines.last() else { return false }; - let prev = &lines[p]; - let g = prev.origin.baseline - l.origin.baseline; - prev.stream == l.stream - && same_font(prev, l.font.as_bytes(), l.face.as_deref()) - && prev.base_font == l.base_font - && prev.bold == l.bold - && prev.italic == l.italic - && prev.color == l.color - && (prev.size - l.size).abs() < 0.01 - && aligned(prev, l) - && g > l.size * 0.8 - && g < l.size * 2.5 - && gap.is_none_or(|first| (g - first).abs() < first * 0.2) - }); - if joins - && let Some(b) = blocks.last_mut() - && let Some(&p) = b.lines.last() - { - let prev = &lines[p]; - gap.get_or_insert(prev.origin.baseline - l.origin.baseline); - if b.text.ends_with('-') { - b.text.pop(); - } else { - b.text.push(' '); - } - b.text.push_str(l.text.trim_matches(paragraph_separator)); - b.rect = [b.rect[0].min(l.rect[0]), b.rect[1].min(l.rect[1]), b.rect[2].max(l.rect[2]), b.rect[3].max(l.rect[3])]; - b.lines.push(i); - } else { - gap = None; - blocks.push(TextBlock { - text: l.text.trim_matches(paragraph_separator).to_string(), - rect: l.rect, - base_font: l.base_font.clone(), - size: l.size, - bold: l.bold, - italic: l.italic, - color: l.color, - lines: vec![i], - }); - } - } - blocks + Ok(regions::group_reading_blocks(&lines)) } // An ideographic space carries intentional Japanese spacing, including paragraph indentation. @@ -1255,6 +1555,10 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) /// Formatting for a rewritten paragraph (`None` keeps the paragraph's own). #[derive(Clone, Debug, Default, PartialEq)] pub struct BlockStyle { + /// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style. + pub ranges: Vec, + /// Explicit page font resource (from register_text_font or the page font inventory). + pub resource: Option, /// A standard font family, bold, italic. pub family: Option<(crate::added::Family, bool, bool)>, /// Override weight without choosing a different family (`None` keeps the source weight). @@ -1277,6 +1581,18 @@ pub struct BlockStyle { pub width: Option, } +#[derive(Clone, Debug, PartialEq)] +pub struct RangeStyle { + pub range: std::ops::Range, + pub family: Option<(crate::added::Family, bool, bool)>, + pub resource: Option, + pub size: Option, + pub color: Option<[f64; 3]>, + pub underline: Option, + pub char_spacing: Option, + pub scale: Option, +} + /// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width. pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option<&str>, style: &BlockStyle) -> Result { rewrite_block_with_limit(doc, page, block, text, style, MAX_TYPE3_GLYPHS) @@ -1295,10 +1611,34 @@ pub(crate) fn rewrite_block_with_limit( let blocks = group_blocks(&lines); let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); - let first = members[0]; + let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let registered_face = fonts_res.iter().any(|(name, _)| name.starts_with(b"PCFull")); + // Hebrew is written back in drawing order (and with the Hebrew fallback face) by the + // single-style path below; the styled region path does not reorder right-to-left runs. + let hebrew = text.unwrap_or(&b.text).chars().any(crate::bidi::is_hebrew); + if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid( + "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), + )); + } + if !hebrew + && (!style.ranges.is_empty() + || style.resource.is_some() + || (text.is_some_and(|draft| { + draft.contains('\n') + || pdfcraft_fonts::source_shaping::required(draft) + || !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty() + }) || registered_face + || b.rows.iter().any(|row| row.len() > 1) + || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color)) + && style.family.is_none() + && style.bold.is_none()) + { + return rewrite_styled_block(doc, page, &b, &lines, text, style); + } let streams = content_streams(doc, &p.dict); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; @@ -1318,7 +1658,8 @@ pub(crate) fn rewrite_block_with_limit( } let o_state = ts_state.clone(); let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); - let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| m.encode(&text).is_some()); + let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &text)); + let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); @@ -1368,15 +1709,7 @@ pub(crate) fn rewrite_block_with_limit( let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text, family, bold, max_glyphs)?) } else { None }; let advance = |s: &str| -> f64 { let t = match (&metrics, reuse) { - (Some(m), true) => m - .encode(s) - .map(|bytes| { - m.codes(&bytes) - .iter() - .map(|(c, l)| m.width(*c) * size + o_state.char_spacing + if m.is_space(*c, *l) { o_state.word_spacing } else { 0.0 }) - .sum::() - }) - .unwrap_or(0.0), + (Some(m), true) => source_width(m, s, size, space, o_state.char_spacing, o_state.word_spacing), _ if let Some(fallback) = &type3 => { // Each character's count in `s`, from one pass over `s`. let mut counts: HashMap = HashMap::new(); @@ -1480,12 +1813,17 @@ pub(crate) fn rewrite_block_with_limit( tw_set = tw; } // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). - for (font, bytes) in encode(&crate::bidi::to_visual(line)).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != shown_font { - block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); - shown_font = font; + let visual = crate::bidi::to_visual(line); + if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { + block_ops.extend(source_show(metrics, &visual, size, space, o_state.char_spacing, tw)?); + } else { + for (font, bytes) in encode(&visual).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if font != shown_font { + block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); + shown_font = font; + } + block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); } - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); } let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k; underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); @@ -1651,7 +1989,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None); + let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false); let mut patches = HashMap::new(); // Keep full precision: a tiny rounded text-space change can be magnified by a CTM, // and restoring rise/spacing must not alter following unselected text. diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs new file mode 100644 index 000000000..56557784e --- /dev/null +++ b/crates/edit/src/text_regions.rs @@ -0,0 +1,696 @@ +//! Geometry-based regions and mixed-style rewriting of existing text. + +use super::*; + +fn same_plane(a: &TextLine, b: &TextLine) -> bool { + let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); + let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm)); + a.scope == b.scope + && [0, 2].iter().all(|axis| { + let ax = at.0.get(*axis).copied().unwrap_or(0.0); + let ay = at.0.get(axis + 1).copied().unwrap_or(0.0); + let bx = bt.0.get(*axis).copied().unwrap_or(0.0); + let by = bt.0.get(axis + 1).copied().unwrap_or(0.0); + let al = ax.hypot(ay); + let bl = bx.hypot(by); + al > 0.0 && bl > 0.0 && (ax / al - bx / bl).abs() < 0.001 && (ay / al - by / bl).abs() < 0.001 + }) +} + +fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] +} + +fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { + row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) +} + +pub(super) fn group_blocks(lines: &[TextLine]) -> Vec { + group_layout(lines, true) +} + +/// Exporters currently represent a block with one style. Keep their style boundaries until +/// they support rich runs, while edit selection may encompass the complete visual region. +pub(super) fn group_reading_blocks(lines: &[TextLine]) -> Vec { + group_layout(lines, false) +} + +fn same_style(a: &TextLine, b: &TextLine) -> bool { + same_font(a, b.font.as_bytes(), b.face.as_deref()) + && a.base_font == b.base_font + && a.bold == b.bold + && a.italic == b.italic + && a.color == b.color + && (a.size - b.size).abs() < 0.01 +} + +fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { + let mut rows: Vec> = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| { + let gap = line.rect[0] - prev.rect[2]; + (mixed || same_style(prev, line)) + && same_plane(prev, line) + && (prev.origin.baseline - line.origin.baseline).abs() < prev.size.min(line.size) * 0.2 + && gap >= -prev.size.min(line.size) * 0.2 + && gap < prev.size.max(line.size) * 1.5 + // Separate same-style text objects that merely touch or overlap, with no space + // between them, are separate labels (for example a paragraph dragged against + // another, #594). Style runs and words separated by a space or gap still join. + && (prev.origin.bt_op == line.origin.bt_op + || gap > prev.size.min(line.size) * 0.01 + || !same_style(prev, line) + || prev.text.ends_with(char::is_whitespace) + || line.text.starts_with(char::is_whitespace)) + }); + if joins && let Some(row) = rows.last_mut() { + row.push(i); + } else { + rows.push(vec![i]); + } + } + let mut blocks: Vec = Vec::new(); + for row in rows { + let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue }; + let Some(rect) = row_rect(&row, lines) else { continue }; + let joins = blocks.last().is_some_and(|block| { + let Some(prev_row) = block.rows.last() else { return false }; + let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false }; + let Some(pr) = row_rect(prev_row, lines) else { return false }; + let gap = prev.origin.baseline - first.origin.baseline; + let tol = prev.size.max(first.size).max(1.0); + let aligned = (pr[0] - rect[0]).abs() < tol || ((pr[0] + pr[2] - rect[0] - rect[2]) / 2.0).abs() < tol || (pr[2] - rect[2]).abs() < tol; + (mixed || same_style(prev, first)) + && same_plane(prev, first) + && aligned + && first.size.min(prev.size) >= first.size.max(prev.size) * 0.4 + && gap > first.size.min(prev.size) * 0.8 + && gap < first.size.max(prev.size) * 2.5 + }); + if !joins { + blocks.push(TextBlock { + text: String::new(), + editor_text: String::new(), + spans: Vec::new(), + rect, + base_font: first.base_font.clone(), + size: first.size, + bold: first.bold, + italic: first.italic, + color: first.color, + lines: Vec::new(), + rows: Vec::new(), + }); + } + let Some(block) = blocks.last_mut() else { continue }; + let mut row_text = String::new(); + if !block.rows.is_empty() { + block.editor_text.push('\n'); + } + for &index in &row { + let Some(line) = lines.get(index) else { continue }; + // Some producers position separate words without encoding a space. + if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) + && !row_text.ends_with(char::is_whitespace) + && !line.text.starts_with(char::is_whitespace) + && line.rect[0] - prev.rect[2] > line.size * 0.12 + { + row_text.push(' '); + block.editor_text.push(' '); + } + let start = block.editor_text.len(); + block.editor_text.push_str(&line.text); + block.spans.push((start..block.editor_text.len(), index)); + row_text.push_str(&line.text); + block.lines.push(index); + } + if !block.text.is_empty() { + if block.text.ends_with('-') { + block.text.pop(); + } else { + block.text.push(' '); + } + } + block.text.push_str(row_text.trim_matches(paragraph_separator)); + block.rect = union(block.rect, rect); + block.rows.push(row); + } + blocks +} + +/// Shape in the source or an explicitly registered matching full face. Never use interface fonts. +fn shape_source( + doc: &Document, + fonts: &Dict, + name: &str, + text: &str, +) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { + if !pdfcraft_fonts::source_shaping::required(text) { + return Ok(None); + } + let source = + fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?; + let family = Metrics::from_dict(doc, &source).base_font; + let mut candidates = vec![source]; + candidates.extend( + fonts + .iter() + .filter_map(|(_, f)| doc.dict(f)) + .filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)), + ); + for font in candidates { + if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) + && pdfcraft_fonts::embedding::covers(&bytes, text) + && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) + { + return Ok(Some((bytes, shaped, family))); + } + } + Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) +} + +/// Rewrite visual rows while keeping the source style on each surviving character. Resource +/// names may differ between Unicode subsets of the same face; encoding searches those subsets. +pub(super) fn rewrite_styled_block( + doc: &mut Document, + page: usize, + block: &TextBlock, + lines: &[TextLine], + text: Option<&str>, + style: &BlockStyle, +) -> Result { + let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; + let draft = match text { + Some(t) if t != block.text => t, + _ => &block.editor_text, + }; + if draft.len() > 4_000_000 { + return Err(EditError::Invalid("the replacement text is too large".into())); + } + if draft == block.editor_text && *style == BlockStyle::default() { + return Ok(LineEdit { substituted: None }); + } + let mut ranges: Vec<_> = style.ranges.iter().collect(); + ranges.sort_by_key(|r| r.range.start); + if ranges.len() > 1024 + || ranges.iter().any(|r| { + r.range.start >= r.range.end + || draft.get(r.range.clone()).is_none() + || [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite()) + || r.size.is_some_and(|n| n <= 0.0 || n > 14400.0) + || r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))) + }) + || ranges.windows(2).any(|r| r[0].range.end > r[1].range.start) + { + return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); + } + let mut styled_lines = lines.to_vec(); + let mut styled_spans = Vec::new(); + let mut underlines = HashMap::new(); + let mut overrides = HashMap::new(); + for (span, source) in block.edited_spans(draft) { + let mut edges = vec![span.start, span.end]; + for r in &ranges { + for edge in [r.range.start, r.range.end] { + if span.contains(&edge) { + edges.push(edge); + } + } + } + edges.sort_unstable(); + edges.dedup(); + for bounds in edges.windows(2) { + let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; + let mut index = source; + let selected = ranges.iter().find(|r| r.range.contains(&start)).copied(); + if selected.is_some() || style.family.is_some() || style.bold.is_some() { + let default_range = RangeStyle { + range: start..end, + family: None, + resource: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let r = selected.unwrap_or(&default_range); + let mut line = lines.get(source).unwrap_or(first).clone(); + if let Some((family, bold, italic)) = + r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) + { + let base = family.base_font(bold, italic); + line.font = format!("PCRange{}", base.replace('-', "")); + line.base_font = base.to_owned(); + line.bold = bold; + line.italic = italic; + } + if let Some(resource) = &r.resource { + line.font = resource.clone(); + } + if let Some(size) = r.size { + line.size = size; + } + if let Some(color) = r.color { + line.color = color; + line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; + } + if let Some(spacing) = r.char_spacing { + line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); + } + if let Some(scale) = r.scale { + line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); + } + index = styled_lines.len(); + styled_lines.push(line); + if let Some(r) = selected { + overrides.insert(index, r); + } + if let Some(underline) = r.underline { + underlines.insert(index, underline); + } + } + styled_spans.push((start..end, index)); + } + } + let lines = styled_lines.as_slice(); + let p = page_dict(doc, page)?; + let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f)); + } + let mut metrics = std::collections::BTreeMap::new(); + for (name, object) in fonts.iter().take(256) { + if let Some(font) = doc.resolve(object).as_dict() { + metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); + } + } + for index in &block.lines { + if let Some(line) = lines.get(*index) + && !metrics.contains_key(&line.font) + && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()) + { + metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); + } + } + if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite()) + || style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite())) + { + return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); + } + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { + return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); + } + let width = style.width.unwrap_or(block.rect[2] - block.rect[0]).clamp(1.0, 14_400.0); + // Each tuple is a character, its source style and an encodable resource name. + let mut chars = Vec::new(); + let mut substituted = None; + let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new(); + let mut full_faces: HashMap = HashMap::new(); + for (range, index) in styled_spans { + let source = lines.get(index).unwrap_or(first); + for ch in draft.get(range).unwrap_or_default().chars() { + if ch == '\n' { + chars.push((ch, index, source.font.clone())); + continue; + } + let s = ch.to_string(); + let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { + if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { + return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch)))); + } + resource.clone() + } else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) { + source.font.clone() + } else if let Some((name, _)) = metrics.iter().find(|(_, m)| { + pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s) + }) { + name.clone() + } else if let Some(face) = + pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s)) + { + let key = face.name(); + if let Some(name) = full_faces.get(&key) { + name.clone() + } else { + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?; + let font = fonts + .get(name.as_bytes()) + .and_then(|f| doc.dict(f)) + .ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + full_faces.insert(key, name.clone()); + name + } + } else { + let family = source_family(&source.base_font); + if needs_type3(&s) { + let key = (source.base_font.rsplit('+').next().unwrap_or(&source.base_font).to_owned(), ch); + if let Some(name) = fallback_names.get(&key) { + chars.push((ch, index, name.clone())); + continue; + } + if fallback_names.len() >= MAX_TYPE3_GLYPHS { + return Err(EditError::Invalid("the replacement has too many fallback glyphs".into())); + } + let fallback = type3_font(doc, &mut fonts, &s, family, source.bold, MAX_TYPE3_GLYPHS)?; + let name = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; + let dict = fonts + .get(name.as_bytes()) + .and_then(|f| doc.resolve(f).as_dict().cloned()) + .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &dict)); + substituted = Some(fallback.family.to_string()); + fallback_names.insert(key, name.clone()); + name + } else { + let base = family.base_font(source.bold, source.italic); + let name = format!("PCEd{}", base.replace('-', "")); + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f)); + fonts.set(name.clone().into_bytes(), Object::Dict(f)); + substituted = Some(base.to_string()); + name + } + }; + chars.push((ch, index, name)); + } + } + let size_of = |source: &TextLine, index: usize| { + overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) + }; + // A source run may carry TJ kerning/justification that is absent from its text state. + // Keep its measured advance when rebuilding the run instead of making it wrap on entry. + let mut calibration = HashMap::new(); + for (index, source) in lines.iter().enumerate() { + if let Some(m) = metrics.get(&source.font) { + let nominal = source_width( + m, + &source.text, + source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), + source_space(doc, &fonts, m), + source.origin.state.char_spacing, + source.origin.state.word_spacing, + ) * source.origin.state.scale + * source.origin.k; + let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some()); + let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; + calibration.insert(index, factor); + } + } + let advance = |item: &(char, usize, String)| -> f64 { + let source = lines.get(item.1).unwrap_or(first); + let Some(m) = metrics.get(&item.2) else { return 0.0 }; + source_width( + m, + &item.0.to_string(), + size_of(source, item.1), + source_space(doc, &fonts, m), + overrides + .get(&item.1) + .and_then(|r| r.char_spacing) + .or(style.char_spacing) + .map_or(source.origin.state.char_spacing, |c| c / source.origin.k.max(1e-6)), + source.origin.state.word_spacing, + ) * overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)) + * source.origin.k + * calibration.get(&item.1).copied().unwrap_or(1.0) + }; + let measure = |items: &[(char, usize, String)]| -> Result { + let mut width = 0.0; + let mut at = 0; + while let Some(item) = items.get(at) { + let mut end = at + 1; + while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { + end += 1; + } + let segment = items.get(at..end).unwrap_or_default(); + let string: String = segment.iter().map(|v| v.0).collect(); + if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? { + let source = lines.get(item.1).unwrap_or(first); + let scale = + overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)); + let spacing = overrides + .get(&item.1) + .and_then(|r| r.char_spacing) + .or(style.char_spacing) + .map_or(source.origin.state.char_spacing, |s| s / source.origin.k.max(1e-6)); + width += (shaped.advance * size_of(source, item.1) + + spacing * string.chars().count() as f64 + + source.origin.state.word_spacing * string.matches(' ').count() as f64) + * scale + * source.origin.k; + } else { + width += segment.iter().map(&advance).sum::(); + } + at = end; + } + Ok(width) + }; + let mut rows: Vec> = Vec::new(); + let mut row = Vec::new(); + let mut row_width = 0.0; + // Wrap at word boundaries, retaining explicit source row breaks and whitespace. + let mut at = 0; + while at < chars.len() { + let Some(item) = chars.get(at) else { break }; + if item.0 == '\n' { + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + at += 1; + continue; + } + let mut end = at + 1; + if !item.0.is_whitespace() { + while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) { + end += 1; + } + } + let word = chars.get(at..end).unwrap_or_default(); + let w = measure(word)?; + if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 { + while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { + row.pop(); + } + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + } + row.extend_from_slice(word); + row_width += w; + at = end; + } + if !row.is_empty() { + rows.push(row); + } + for row in &rows { + let text: String = row.iter().map(|v| v.0).collect(); + if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { + return Err(EditError::Invalid( + "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" + .into(), + )); + } + } + let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; + let mut shaped_runs = HashMap::new(); + let mut segment_advances = HashMap::new(); + for (row_index, row) in rows.iter().enumerate() { + let mut at = 0; + while let Some(item) = row.get(at) { + let mut end = at + 1; + while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { + end += 1; + } + segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::()); + let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect(); + if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? { + shaped_runs.insert((row_index, at), (bytes, shaped, family)); + } + at = end; + } + } + let mut glyph_resources = HashMap::new(); + for (key, (bytes, shaped, family)) in &shaped_runs { + let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + glyph_resources.insert(*key, resource); + } + let n = pdfcraft_content::num; + let mut block_ops = vec![Op::new("q", vec![])]; + let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0]) + .invert() + .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; + let (tx, ty) = back.apply(dx, dy); + block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); + let centre = (block.rect[0] + block.rect[2]) / 2.0; + let lefts: Vec<_> = block.rows.iter().filter_map(|r| row_rect(r, lines).map(|r| r[0])).collect(); + let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min); + let centered = block.rows.len() > 1 + && left_span > first.size * 0.5 + && block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < first.size)); + let source_leading = block + .rows + .windows(2) + .filter_map(|rows| { + let a = rows[0].first().and_then(|i| lines.get(*i))?; + let b = rows[1].first().and_then(|i| lines.get(*i))?; + Some((a.origin.baseline - b.origin.baseline).abs()) + }) + .rfind(|v| *v > 0.0) + .unwrap_or(first.size * 1.5); + let mut baseline = first.origin.baseline; + for (row_index, row) in rows.iter().enumerate() { + let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i)); + if style.line_spacing.is_none() + && style.size.is_none() + && let Some(anchor) = anchor + { + baseline = anchor.origin.baseline; + } + let anchor = anchor.unwrap_or(first); + let w = measured.get(row_index).copied().unwrap_or(0.0); + let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); + let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); + let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] }; + let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width }; + let mut x = left + + match align { + crate::added::Align::Center => (available - w) / 2.0, + crate::added::Align::Right => available - w, + _ => 0.0, + }; + let mut pos = 0; + while pos < row.len() { + let Some(item) = row.get(pos) else { break }; + let source = lines.get(item.1).unwrap_or(first); + let mut end = pos + 1; + while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) { + end += 1; + } + let segment = row.get(pos..end).unwrap_or_default(); + let string: String = segment.iter().map(|c| c.0).collect(); + let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?; + let mut state = source.origin.state.clone(); + state.font = Some((item.2.clone(), size_of(source, item.1))); + if let Some(c) = overrides.get(&item.1).and_then(|r| r.char_spacing).or(style.char_spacing) { + state.char_spacing = c / source.origin.k.max(1e-6); + } + if let Some(s) = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale) { + state.scale = (s / 100.0).clamp(0.1, 10.0); + } + if !shaped_runs.contains_key(&(row_index, pos)) { + state.scale *= calibration.get(&item.1).copied().unwrap_or(1.0); + } + if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { + state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; + } + let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; + let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); + tm[4] += ax; + tm[5] += ay; + let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0); + if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { + let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + let mut properties = Dict::new(); + properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string))); + block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); + state.font = Some((resource.clone(), size_of(source, item.1))); + // Absolute glyph matrices preserve marks and contextual positions; font-program + // codes are separate from Unicode and ToUnicode/ActualText keep logical text. + let size = size_of(source, item.1); + let mut extra = 0.0; + for (i, glyph) in shaped.glyphs.iter().enumerate() { + let mut matrix = tm; + let x = (glyph.position[0] * size + extra) * state.scale; + let y = glyph.position[1] * size; + matrix[4] += matrix[0] * x + matrix[2] * y; + matrix[5] += matrix[1] * x + matrix[3] * y; + let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; + block_ops.push(Op::new("BT", vec![])); + block_ops.extend(state.ops()); + block_ops.push(Op::new("Tm", matrix.map(n).to_vec())); + block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.to_be_bytes().to_vec()))])); + block_ops.push(Op::new("ET", vec![])); + extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64; + } + block_ops.push(Op::new("EMC", vec![])); + segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k; + } else { + block_ops.push(Op::new("BT", vec![])); + block_ops.extend(state.ops()); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); + block_ops.extend(source_show( + m, + &string, + size_of(source, item.1), + source_space(doc, &fonts, m), + state.char_spacing, + state.word_spacing, + )?); + block_ops.push(Op::new("ET", vec![])); + } + if underlines.get(&item.1).copied().or(style.underline) == Some(true) { + let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); + let underline_size = size_of(source, item.1) * source.origin.k; + let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); + let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); + block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); + block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))])); + block_ops.push(Op::new("m", vec![n(x0), n(y0)])); + block_ops.push(Op::new("l", vec![n(x1), n(y1)])); + block_ops.push(Op::new("S", vec![])); + } + x += segment_width; + pos = end; + } + baseline -= if style.line_spacing.is_none() && style.size.is_none() { + source_leading + } else { + style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) + }; + } + block_ops.push(Op::new("Q", vec![])); + let streams = content_streams(doc, &p.dict); + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let rects: Vec<_> = members.iter().map(|l| l.rect).collect(); + let mut drop = coincident_ops(lines, &rects); + drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); + let o = &first.origin; + let start = first.ops.first().copied().unwrap_or(o.bt_op); + let split = ops + .get(o.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); + let after = if split { &o.state } else { &o.bt_state }; + block_ops.extend(after.ops()); + if split { + block_ops.insert(0, Op::new("ET", vec![])); + block_ops.push(Op::new("BT", vec![])); + block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + } + let at = if split { start } else { o.bt_op }; + let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i))); + let contents = rewritten_contents(doc, &streams, data); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), contents); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + Ok(LineEdit { substituted }) +} diff --git a/crates/engine/README.md b/crates/engine/README.md index c0553769d..982d2fb1a 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -78,3 +78,7 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`). - Performance budgets (`misc.performance-budgets`) are not set or enforced. - Some shipped features are still reachable only from the UI, not through an `automation` tool; `cargo xtask parity` lists them; closing that gap is tracked in the execution plan (D4). + +`doc.text_edit_preview(page, block)` prepares a read-only page background with the selected +paragraph’s glyphs removed, preserving text positioning, images and colours. It shares the +working document’s render configuration and never enters the undo history. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 489a407f9..ed0512165 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,10 +26,13 @@ pub mod links; pub mod ocr; pub mod optimizer; pub mod signature_image; +pub mod text_fonts; +mod text_preview; mod upright_image; pub mod xfa; pub use signature_image::{ImageSignaturePreview, SignatureImage}; +pub use text_preview::TextEditPreview; pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges}; @@ -49,7 +52,7 @@ pub use pdfcraft_forms::{ }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable}; +pub use pdfcraft_edit::{BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, first_undrawable}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; @@ -1263,6 +1266,11 @@ pub enum Edit { /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. style: pdfcraft_edit::BlockStyle, }, + /// An explicit open-licensed full font program, completing subsets or selected by a user. + RegisterTextFont { + page: usize, + bytes: Arc>, + }, /// Order tabs manually: move a field one place earlier or later on its page. MoveInTabOrder { name: String, @@ -1539,6 +1547,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete document JavaScript", "Edit document JavaScript", "Edit text", + "Load document font", "Move object", "Move objects", "Move image", @@ -1662,6 +1671,7 @@ impl Edit { Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::RegisterTextFont { .. } => Label::of("Load document font"), Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), Edit::EditPageImage { change, .. } => Label::of(match change { ImageEdit::Move(_) => "Move image", @@ -1845,6 +1855,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::ConvertPdfA { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } + | Edit::RegisterTextFont { .. } | Edit::EditPageImage { .. } | Edit::Flatten { .. } | Edit::FlattenFillSign => { @@ -2134,10 +2145,15 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; let c = match change { ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.rect, *q, false, false)), - ImageEdit::Flip { horizontal } => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.rect, 0, *horizontal, !*horizontal)) + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), ImageEdit::Replace { name, bytes } => { if img.is_form { return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( @@ -2154,6 +2170,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::EditTextBlock { page, block, text, style } => { pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; } + Edit::RegisterTextFont { page, bytes } => { + pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; + } Edit::MarkDecorative { figure } => { let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs new file mode 100644 index 000000000..f4945f756 --- /dev/null +++ b/crates/engine/src/text_fonts.rs @@ -0,0 +1,78 @@ +//! Explicit document-font discovery. Interface fallbacks never enter this catalogue. +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug)] +pub struct FontFile { + pub path: PathBuf, + pub family: String, + pub face: String, +} + +/// Standard user-installed font folders; discovery is performed only on request. +pub fn installed_directories() -> Vec { + let home = std::env::var_os("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + let mut dirs = vec![PathBuf::from("/Library/Fonts")]; + if let Some(home) = home { + dirs.push(home.join("Library/Fonts")); + } + dirs + } else if cfg!(windows) { + let mut dirs = std::env::var_os("WINDIR").map(|p| PathBuf::from(p).join("Fonts")).into_iter().collect::>(); + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(local).join("Microsoft/Windows/Fonts")); + } + dirs + } else { + let mut dirs = vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")]; + if let Some(home) = home { + dirs.extend([home.join(".local/share/fonts"), home.join(".fonts")]); + } + dirs + } +} + +/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed. +/// The caller controls roots (automation resolves confinement before calling this). +pub fn discover(roots: &[PathBuf]) -> Vec { + use std::io::Read; + let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect(); + let mut files = Vec::new(); + let mut entries = 0usize; + let mut budget = 64u64 << 20; + let mut seen = std::collections::HashSet::new(); + while let Some((path, depth)) = pending.pop() { + if entries >= 4096 || budget == 0 { + break; + } + entries += 1; + let Ok(meta) = std::fs::symlink_metadata(&path) else { continue }; + if meta.file_type().is_symlink() || !seen.insert(path.clone()) { + continue; + } + if meta.is_dir() && depth < 4 { + if let Ok(children) = std::fs::read_dir(&path) { + let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len())); + pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1))); + } + } else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) { + if meta.len() > budget { + continue; + } + budget = budget.saturating_sub(meta.len()); + let mut bytes = Vec::new(); + let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes)); + if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { + continue; + } + if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) { + files.push(FontFile { path, family, face }); + } + } + } + files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path))); + files +} +fn font_extension(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) +} diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs new file mode 100644 index 000000000..d72b49d1e --- /dev/null +++ b/crates/engine/src/text_preview.rs @@ -0,0 +1,66 @@ +//! The page beneath an inline text editor, with the source glyphs removed on a COS clone. + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage}; + +use crate::{Document, guard}; + +pub struct TextEditPreview { + page: usize, + bytes: Arc>, + config: RenderConfig, +} + +impl TextEditPreview { + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.config.clone()) + } + + pub fn render_background(&self, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone()); + let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? + } +} + +impl Document { + /// Measure a draft without changing bytes, dirty state, or undo history. + pub fn text_edit_layout( + &self, + page: usize, + block: usize, + text: &str, + style: &pdfcraft_edit::BlockStyle, + ) -> Result { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_edit::text::text_edit_layout(&editor.cos, page, block, text, style).map_err(|e| e.to_string()) + })? + } + + /// Embedded source faces for a text editor, bounded and validated as sfnt programs. + /// This is read-only; no source font is installed or exported. + pub fn text_editor_fonts(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::text::text_editor_fonts(&e.cos, page).ok()).unwrap_or_default() + } + + /// Preparing the background never changes working bytes, dirty state or undo history. + pub fn text_edit_preview(&self, page: usize, block: usize) -> Result { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + pdfcraft_edit::clear_block(&mut cos, page, block).map_err(|e| e.to_string())?; + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(TextEditPreview { page, bytes, config: self.config.clone() }) + })? + } +} diff --git a/crates/engine/tests/document_fonts.rs b/crates/engine/tests/document_fonts.rs new file mode 100644 index 000000000..ebeff24ae --- /dev/null +++ b/crates/engine/tests/document_fonts.rs @@ -0,0 +1,100 @@ +//! Document font selection and contextual glyph save/reopen acceptance on synthetic PDFs. +mod support { + pub mod font_fixture; +} +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; +use pdfcraft_engine::{BlockStyle, Edit, Session}; +use pdfcraft_render::{PageRenderer, RenderRequest}; +use std::sync::Arc; +fn document() -> Arc> { + let mut d = Document::new_empty(); + let pages = d.add(Object::Dict(Dict::new())); + let contents = d.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F1 18 Tf 30 160 Td (Source) Tj ET"))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(font)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 300, 240].map(Object::Int).to_vec())); + let page = d.add(Object::Dict(p)); + d.update_dict(pages, |p| { + p.set(b"Type".to_vec(), Object::name("Pages")); + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + p.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + let mut root = Dict::new(); + root.set(b"Type".to_vec(), Object::name("Catalog")); + root.set(b"Pages".to_vec(), Object::Ref(pages)); + let root = d.add(Object::Dict(root)); + d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap()) +} +fn pixels(bytes: Arc>) -> Vec { + let mut renderer = PageRenderer::new(bytes, Default::default()); + let p = renderer.render(RenderRequest { page: 0, scale: 2.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + p.rgba.to_vec() +} +fn exercise(font: Vec, text: &str, expected_family: &str) { + let mut s = Session::new(); + let id = s.open("source.pdf", None, document(), None).unwrap(); + let original = pixels(s.get(id).unwrap().display.clone()); + s.apply(id, Edit::RegisterTextFont { page: 0, bytes: Arc::new(font.clone()) }).unwrap(); + let resource = s.get(id).unwrap().text_editor_fonts(0).into_iter().find(|f| f.bytes == font).unwrap().resource; + let style = BlockStyle { resource: Some(resource), width: Some(240.0), ..Default::default() }; + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: text.into(), style: style.clone() }).unwrap(); + let edited = s.get(id).unwrap(); + let lines = edited.text_lines(0); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), text); + assert!(lines.iter().all(|l| l.base_font == expected_family)); + assert!((lines[0].baseline()[1] - 160.0).abs() < 0.01); + let current = pixels(edited.display.clone()); + assert_ne!(current, original); + let saved = s.save_bytes(id).unwrap(); + let re = s.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(pixels(s.get(re).unwrap().display.clone()), current); + assert_eq!(s.get(re).unwrap().text_blocks(0)[0].editor_text, text); + let twice = format!("{text} {text}"); + s.apply(re, Edit::EditTextBlock { page: 0, block: 0, text: twice.clone(), style: style.clone() }).unwrap(); + assert_eq!(s.get(re).unwrap().text_blocks(0)[0].editor_text, twice); + let again = s.save_bytes(re).unwrap(); + let next = s.open("twice.pdf", None, again, None).unwrap(); + assert_eq!(s.get(next).unwrap().text_blocks(0)[0].editor_text, twice); + assert_eq!(pixels(s.get(next).unwrap().display.clone()), pixels(s.get(re).unwrap().display.clone())); + s.apply(re, Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style }).unwrap(); + assert!(s.get(re).unwrap().text_lines(0).is_empty(), "every contextual glyph is deleted"); + let empty = pixels(s.get(re).unwrap().display.clone()); + assert!(empty.as_chunks::<4>().0.iter().all(|p| p[..3] == [255; 3])); + s.undo(id).unwrap(); + assert_eq!(pixels(s.get(id).unwrap().display.clone()), original); +} +#[test] +fn opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen() { + exercise(support::font_fixture::cff_font(), "A A", "RectFont-Regular"); +} +#[test] +fn contextual_arabic_marks_ligatures_and_mixed_bidi_keep_logical_text_after_reopen() { + if let Some(path) = std::env::var_os("PDFCRAFT_TEST_ARABIC_FONT") { + exercise(std::fs::read(path).unwrap(), "سَلَام 123", "NotoSansArabic-Regular"); + return; + } + let Some(font) = pdfcraft_fonts::document_arabic_font() else { + eprintln!("set CRAFT_FONTS_DIR to test full Arabic glyph programs"); + return; + }; + if pdfcraft_fonts::embedding::validate(font.bytes).is_err() { + eprintln!("set PDFCRAFT_TEST_ARABIC_FONT to an openly licensed static Noto Sans Arabic face"); + return; + } + exercise(font.bytes.to_vec(), "سَلَام 123", "NotoSansArabic-Regular"); +} diff --git a/crates/engine/tests/private_image_resize.rs b/crates/engine/tests/private_image_resize.rs new file mode 100644 index 000000000..b12eb25a0 --- /dev/null +++ b/crates/engine/tests/private_image_resize.rs @@ -0,0 +1,110 @@ +//! Opt-in acceptance checks. Private files, pixels and font programs never become fixtures. + +use std::sync::Arc; + +use pdfcraft_engine::{Edit, ImageEdit, Session}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; + +fn pixels(bytes: Arc>, page: usize) -> pdfcraft_render::RenderedPage { + let mut renderer = PageRenderer::new(bytes, Default::default()); + let rendered = renderer.render(RenderRequest { page, kind: RequestKind::Pixels, scale: 1.5, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + assert!(rendered.warnings.is_empty()); + rendered +} + +fn near(actual: [f64; 4], expected: [f64; 4]) { + assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.01), "{actual:?} vs {expected:?}"); +} + +#[test] +#[ignore = "requires an explicitly supplied local PDF with cropped artwork"] +fn private_cropped_images_resize_save_reopen_and_undo() { + let path = std::env::var("PDFCRAFT_CLIPPED_ARTWORK_PDF").expect("supply a local PDF path"); + let original = Arc::new(std::fs::read(&path).unwrap()); + let mut session = Session::new(); + let id = session.open("private.pdf", None, original.clone(), None).unwrap(); + let pages = session.get(id).unwrap().info.pages.len(); + let mut checked = 0; + for page in 0..pages { + let images = session.get(id).unwrap().page_images(page); + let original_pixels = pixels(session.get(id).unwrap().display.clone(), page); + let info = session.get(id).unwrap().info.pages[page].clone(); + let text = || { + session + .get(id) + .unwrap() + .text_lines(page) + .into_iter() + .map(|l| (l.text.clone(), l.rect, l.font.clone(), l.size, l.color, l.baseline())) + .collect::>() + }; + let original_text = text(); + for (index, image) in images.iter().enumerate() { + let Some(visible) = image.visible_rect else { continue }; + for factor in [0.7, 1.04] { + // Keep the visible upper-left corner fixed; transform the same cropped pixels. + let transform = |r: [f64; 4]| { + [ + visible[0] + (r[0] - visible[0]) * factor, + visible[3] + (r[1] - visible[3]) * factor, + visible[0] + (r[2] - visible[0]) * factor, + visible[3] + (r[3] - visible[3]) * factor, + ] + }; + session.apply(id, Edit::EditPageImage { page, index, change: ImageEdit::Move(transform(image.rect)) }).unwrap(); + let doc = session.get(id).unwrap(); + let after = doc.page_images(page); + near(after[index].visible_rect.unwrap(), transform(visible)); + near(after[index].rect, transform(image.rect)); + assert_eq!(after[index].object, image.object, "source pixels remain embedded unchanged"); + let current_text = doc + .text_lines(page) + .into_iter() + .map(|l| (l.text.clone(), l.rect, l.font.clone(), l.size, l.color, l.baseline())) + .collect::>(); + assert_eq!(current_text, original_text, "all Chinese and Latin labels stay put"); + for (other, (a, b)) in after.iter().zip(&images).enumerate().filter(|(other, _)| *other != index) { + assert_eq!( + (a.rect, a.visible_rect, a.matrix, a.object), + (b.rect, b.visible_rect, b.matrix, b.object), + "neighbor image {other} unchanged" + ); + } + let live = pixels(doc.display.clone(), page); + assert_eq!((live.width, live.height), (original_pixels.width, original_pixels.height)); + assert_ne!(live.rgba, original_pixels.rgba, "the actual rendered image changes size"); + let dest = after[index].visible_rect.unwrap(); + let union = [visible[0].min(dest[0]), visible[1].min(dest[1]), visible[2].max(dest[2]), visible[3].max(dest[3])]; + let a = info.user_to_view(union[0] as f32, union[1] as f32); + let b = info.user_to_view(union[2] as f32, union[3] as f32); + for (offset, (before, after)) in original_pixels.rgba.as_chunks::<4>().0.iter().zip(live.rgba.as_chunks::<4>().0.iter()).enumerate() { + let x = (offset % live.width as usize) as f32 / 1.5; + let y = (offset / live.width as usize) as f32 / 1.5; + if x < a[0].min(b[0]) - 2.0 || x > a[0].max(b[0]) + 2.0 || y < a[1].min(b[1]) - 2.0 || y > a[1].max(b[1]) + 2.0 { + assert_eq!(before, after, "pixels outside the moved crop stay identical"); + } + } + let saved = session.save_bytes(id).unwrap(); + let scratch = std::env::temp_dir().join(format!("pdfcraft-private-resize-{}.pdf", std::process::id())); + std::fs::write(&scratch, saved.as_slice()).unwrap(); + let disk = Arc::new(std::fs::read(&scratch).unwrap()); + let mut reopened = Session::new(); + let re = reopened.open("saved.pdf", None, disk, None).unwrap(); + near(reopened.get(re).unwrap().page_images(page)[index].visible_rect.unwrap(), dest); + assert_eq!( + pixels(reopened.get(re).unwrap().display.clone(), page).rgba, + live.rgba, + "saved/reopened rendering exactly matches the applied edit" + ); + std::fs::remove_file(scratch).unwrap(); + session.undo(id).unwrap(); + assert_eq!(pixels(session.get(id).unwrap().display.clone(), page).rgba, original_pixels.rgba, "undo restores original rendering"); + checked += 1; + } + } + } + assert!(checked > 0); + assert_eq!(&std::fs::read(path).unwrap(), original.as_ref(), "the supplied file is untouched"); + println!("verified {checked} cropped-image grow/shrink save/reopen/undo cycles on {pages} pages"); +} diff --git a/crates/engine/tests/private_text_glyphs.rs b/crates/engine/tests/private_text_glyphs.rs new file mode 100644 index 000000000..a9d8271ec --- /dev/null +++ b/crates/engine/tests/private_text_glyphs.rs @@ -0,0 +1,82 @@ +//! Private acceptance input stays outside the repository; no private pixels/fonts are emitted. +use pdfcraft_engine::{BlockStyle, Edit, Session}; +use pdfcraft_render::{PageRenderer, RenderRequest}; +use std::sync::Arc; + +fn pixels(bytes: Arc>, page: usize) -> Vec { + let mut renderer = PageRenderer::new(bytes, Default::default()); + let rendered = renderer.render(RenderRequest { page, scale: 1.5, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + assert!(rendered.warnings.is_empty(), "{:?}", rendered.warnings); + rendered.rgba.to_vec() +} + +#[test] +#[ignore = "requires a private Chinese PDF and its full approved face in craft-fonts"] +fn private_new_chinese_glyphs_keep_face_position_save_reopen_and_undo() { + let path = std::env::var("PDFCRAFT_CHINESE_TEXT_PDF").expect("provide a local PDF"); + let original = Arc::new(std::fs::read(&path).unwrap()); + let mut session = Session::new(); + let id = session.open("private.pdf", None, original.clone(), None).unwrap(); + let pages = session.get(id).unwrap().info.pages.len(); + let all: String = (0..pages).flat_map(|p| session.get(id).unwrap().text_lines(p)).map(|l| l.text).collect(); + let new: String = "测试编辑删除龘".chars().filter(|c| !all.contains(*c)).take(4).collect(); + assert!(new.chars().count() >= 2, "the test must insert characters not already in this PDF"); + for page in 0..pages { + let before = session.get(id).unwrap(); + let blocks = before.text_blocks(page); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.editor_text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c))).unwrap(); + let source_lines = before.text_lines(page); + let anchor = source_lines[block.lines[0]].baseline(); + let original_pixels = pixels(before.display.clone(), page); + let source_font_bytes: Vec<_> = + before.text_editor_fonts(page).into_iter().filter(|f| !f.resource.starts_with("PCApproved")).map(|f| (f.resource, f.bytes)).collect(); + let draft = format!("{}{new}", block.editor_text); + let generation = before.edit_generation(); + let layout = before.text_edit_layout(page, index, &draft, &BlockStyle::default()).unwrap(); + assert!(layout.draft_rect.is_some()); + assert_eq!(before.edit_generation(), generation); + session.apply(id, Edit::EditTextBlock { page, block: index, text: draft.clone(), style: BlockStyle::default() }).unwrap(); + let edited = session.get(id).unwrap(); + let lines = edited.text_lines(page); + let added: Vec<_> = lines.iter().filter(|l| l.text.chars().any(|c| new.contains(c))).collect(); + assert!(!added.is_empty()); + for line in added { + assert_eq!(line.base_font, "DroidSansFallback", "same family, not a substitute"); + assert!(line.size > 0.0); + assert_eq!(line.color, source_lines[*block.lines.last().unwrap()].color); + } + let first = lines.iter().find(|l| l.text.starts_with(source_lines[block.lines[0]].text.trim())).unwrap(); + assert!(first.baseline().iter().zip(anchor).all(|(a, b)| (a - b).abs() < 0.01)); + let editor_fonts = edited.text_editor_fonts(page); + for (resource, bytes) in &source_font_bytes { + assert_eq!(&editor_fonts.iter().find(|f| &f.resource == resource).unwrap().bytes, bytes, "source subset is untouched"); + } + let live = pixels(edited.display.clone(), page); + assert_ne!(live, original_pixels); + let saved = session.save_bytes(id).unwrap(); + let scratch = std::env::temp_dir().join(format!("pdfcraft-private-glyphs-{}.pdf", std::process::id())); + std::fs::write(&scratch, saved.as_slice()).unwrap(); + let reopened = session.open("reopened.pdf", None, Arc::new(std::fs::read(&scratch).unwrap()), None).unwrap(); + let reopen = session.get(reopened).unwrap(); + assert_eq!(pixels(reopen.display.clone(), page), live); + assert!(reopen.text_lines(page).iter().any(|l| l.text.contains(&new))); + let content = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::(); + assert_eq!(content(&reopen.text_blocks(page)[index].editor_text), content(&draft)); + let shortened = reopen.text_blocks(page)[index].editor_text.replace(&new, &new.chars().skip(1).collect::()); + session.apply(reopened, Edit::EditTextBlock { page, block: index, text: shortened.clone(), style: BlockStyle::default() }).unwrap(); + let saved = session.save_bytes(reopened).unwrap(); + let after_delete = pixels(session.get(reopened).unwrap().display.clone(), page); + let twice = session.open("deleted-reopened.pdf", None, saved, None).unwrap(); + assert_eq!(pixels(session.get(twice).unwrap().display.clone(), page), after_delete); + assert_eq!(content(&session.get(twice).unwrap().text_blocks(page)[index].editor_text), content(&shortened)); + session.close(twice); + session.close(reopened); + std::fs::remove_file(scratch).unwrap(); + session.undo(id).unwrap(); + assert_eq!(pixels(session.get(id).unwrap().display.clone(), page), original_pixels); + // The app conservatively stays dirty after Undo; the source bytes remain exact. + assert_eq!(session.get(id).unwrap().bytes.as_slice(), original.as_slice()); + } + assert_eq!(std::fs::read(path).unwrap(), original.as_slice()); +} diff --git a/crates/engine/tests/support/font_fixture.rs b/crates/engine/tests/support/font_fixture.rs new file mode 100644 index 000000000..887747452 --- /dev/null +++ b/crates/engine/tests/support/font_fixture.rs @@ -0,0 +1,128 @@ +//! Contributor-original rectangle font, generated entirely in memory: no binary fixture. +fn be16(out: &mut Vec, n: u16) { + out.extend_from_slice(&n.to_be_bytes()); +} +fn be32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} +fn number(n: i16) -> Vec { + let mut v = vec![28]; + v.extend_from_slice(&n.to_be_bytes()); + v +} +fn index(items: &[Vec]) -> Vec { + let mut out = Vec::new(); + be16(&mut out, items.len() as u16); + if items.is_empty() { + return out; + } + out.push(1); + let mut at = 1; + out.push(at); + for i in items { + at += i.len() as u8; + out.push(at); + } + for i in items { + out.extend(i); + } + out +} +pub fn cff_font() -> Vec { + let name = index(&[b"RectFont".to_vec()]); + let mut dict = Vec::new(); + for n in [0, 0, 500, 700] { + dict.extend(number(n)); + } + dict.push(5); + // All CFF integers are fixed-width, so the CharStrings absolute offset is stable. + let offset = 4 + name.len() + index(&[vec![0; dict.len() + 4]]).len() + 4; + dict.extend(number(offset as i16)); + dict.push(17); + let mut rectangle = number(600); + rectangle.extend([139, 139, 21]); + for n in [500, 0, 0, 700, -500, 0, 0, -700] { + rectangle.extend(number(n)); + } + rectangle.extend([5, 14]); + let mut cff = vec![1, 0, 4, 4]; + cff.extend(name); + cff.extend(index(&[dict])); + cff.extend([0, 0, 0, 0]); + cff.extend(index(&[ + vec![14], + { + let mut v = number(300); + v.push(14); + v + }, + rectangle, + ])); + let mut head = vec![0; 54]; + head[0..4].copy_from_slice(&[0, 1, 0, 0]); + head[12..16].copy_from_slice(&0x5f0f3cf5u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000u16.to_be_bytes()); + head[40..42].copy_from_slice(&500i16.to_be_bytes()); + head[42..44].copy_from_slice(&700i16.to_be_bytes()); + let mut hhea = vec![0; 36]; + hhea[0..4].copy_from_slice(&[0, 1, 0, 0]); + hhea[4..6].copy_from_slice(&700i16.to_be_bytes()); + hhea[6..8].copy_from_slice(&(-200i16).to_be_bytes()); + hhea[34..36].copy_from_slice(&3u16.to_be_bytes()); + let mut hmtx = Vec::new(); + for w in [600, 300, 600] { + be16(&mut hmtx, w); + be16(&mut hmtx, 0); + } + let mut maxp = vec![0, 0, 0x50, 0]; + be16(&mut maxp, 3); + let mut cmap = vec![0, 0, 0, 1, 0, 3, 0, 10]; + be32(&mut cmap, 12); + be16(&mut cmap, 12); + be16(&mut cmap, 0); + be32(&mut cmap, 40); + be32(&mut cmap, 0); + be32(&mut cmap, 2); + for (cp, gid) in [(32, 1), (65, 2)] { + be32(&mut cmap, cp); + be32(&mut cmap, cp); + be32(&mut cmap, gid); + } + let names = [(1, "Rect Font"), (2, "Regular"), (6, "RectFont-Regular"), (13, "MIT License")]; + let mut name = Vec::new(); + be16(&mut name, 0); + be16(&mut name, names.len() as u16); + be16(&mut name, (6 + 12 * names.len()) as u16); + let mut data = Vec::new(); + for (id, text) in names { + let bytes: Vec<_> = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for n in [3, 1, 0x409, id, bytes.len() as u16, data.len() as u16] { + be16(&mut name, n); + } + data.extend(bytes); + } + name.extend(data); + let tables = [(*b"CFF ", cff), (*b"cmap", cmap), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"maxp", maxp), (*b"name", name)]; + let mut out = b"OTTO".to_vec(); + be16(&mut out, 7); + be16(&mut out, 64); + be16(&mut out, 2); + be16(&mut out, 48); + let mut payload = Vec::new(); + let mut offset = 12 + 16 * tables.len(); + for (tag, bytes) in tables { + out.extend(tag); + let mut padded = bytes.clone(); + while padded.len() % 4 != 0 { + padded.push(0); + } + let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b))); + be32(&mut out, sum); + be32(&mut out, offset as u32); + be32(&mut out, bytes.len() as u32); + offset += padded.len(); + payload.extend(padded); + } + out.extend(payload); + out +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 2e863845b..9db852e7e 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -14,3 +14,4 @@ pdfcraft-cos = { workspace = true } skrifa = "0.44" # Arabic shaping for text written into PDFs; already in the tree via egui, on the same read-fonts. harfrust = "0.12" +unicode-bidi = "0.3" diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 8b40ed5d9..e2785fc1e 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -70,3 +70,9 @@ Font files are never committed here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). The full font subsystem (parsing, shaping, subsetting and embedding) arrives with M2.2/M7. + + +Document font inputs use `embedding`: bounded static TrueType/OpenType-CFF validation, +exact program preservation, Unicode/CID/GID mapping and full-face subset completion. +`shaping` uses the same program with harfrust and UAX #9; source UI fallback fonts are +never exported. See [existing-text editing](../../docs/editing-existing-text.md). diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs new file mode 100644 index 000000000..fbd361e8b --- /dev/null +++ b/crates/fonts/src/embedding.rs @@ -0,0 +1,277 @@ +//! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are +//! document inputs or the optional craft-fonts build input, never system-interface fallbacks. + +use std::fmt::Write as _; + +use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use skrifa::{ + FontRef, MetadataProvider, + instance::{LocationRef, Size}, + raw::TableProvider, +}; + +/// Compare PDF subset/PostScript names with the full face's family name. +pub fn face_key(name: &str) -> String { + name.rsplit('+').next().unwrap_or(name).chars().filter(char::is_ascii_alphanumeric).flat_map(char::to_lowercase).collect() +} + +/// Full, approved faces of the source family. This never silently selects another family. +pub fn matching_faces(name: &str) -> Vec<&'static crate::CraftFont> { + let key = face_key(name); + crate::CRAFT_FONTS.iter().filter(|f| face_key(f.family) == key || face_key(&f.name()) == key).collect() +} + +/// Whether a complete font program can show every non-newline character. +pub fn covers(bytes: &[u8], text: &str) -> bool { + FontRef::new(bytes).is_ok_and(|f| text.chars().all(|c| matches!(c, '\n' | '\r') || f.charmap().map(c).is_some_and(|g| g.to_u32() != 0))) +} + +/// Names come from the program, never a caller's guessed family. +pub fn names(bytes: &[u8]) -> Result<(String, String), String> { + use skrifa::string::StringId; + validate(bytes)?; + let f = FontRef::new(bytes).map_err(|e| e.to_string())?; + let name = |id| { + f.localized_strings(id) + .find(|s| s.language() == Some("en")) + .or_else(|| f.localized_strings(id).next()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty() && s.len() <= 128) + }; + let family = name(StringId::FAMILY_NAME).ok_or("the font has no family name")?; + let postscript = name(StringId::POSTSCRIPT_NAME).unwrap_or_else(|| family.replace(' ', "")); + Ok((family, postscript)) +} + +/// A selected external font is document input. Require its own open-licence declaration; +/// never turn a proprietary system-interface fallback into an exported PDF font. +pub fn validate_document_input(bytes: &[u8]) -> Result<(), String> { + use skrifa::string::StringId; + let (family, _) = names(bytes)?; + let key = face_key(&family); + if ["sourcehan", "sourceserif", "sourcesans", "notosanscjk", "notoserifcjk"].iter().any(|n| key.starts_with(n)) { + return Err("this font family is excluded by the project's asset policy".into()); + } + let f = FontRef::new(bytes).map_err(|e| e.to_string())?; + let attribution: String = + [StringId::COPYRIGHT_NOTICE, StringId::MANUFACTURER].into_iter().flat_map(|id| f.localized_strings(id)).map(|s| s.to_string()).collect(); + if attribution.to_ascii_lowercase().contains("adobe") { + return Err("Adobe-authored font assets are excluded by the project's asset policy".into()); + } + let license: String = f.localized_strings(StringId::LICENSE_DESCRIPTION).map(|s| s.to_string()).collect(); + if !["SIL OPEN FONT", "SIL Open Font", "Apache License", "Ubuntu Font Licence", "Bitstream Vera", "MIT License"] + .iter() + .any(|l| license.contains(l)) + { + return Err("select an openly licensed font whose name table declares its licence (OFL, Apache, Ubuntu, Bitstream Vera or MIT)".into()); + } + Ok(()) +} + +/// Validate before allocating any PDF objects, so an invalid font cannot partly mutate a doc. +pub fn validate(bytes: &[u8]) -> Result<(), String> { + if bytes.len() > 16 << 20 { + return Err("the selected font exceeds 16 MiB".into()); + } + let font = FontRef::new(bytes).map_err(|_| "select a standalone TrueType/OpenType font (not a font collection)")?; + if font.fvar().is_ok() { + return Err("select a static instance of this variable font before embedding".into()); + } + if (font.glyf().is_err() && font.cff().is_err()) || font.head().is_err() || font.hhea().is_err() || font.hmtx().is_err() { + return Err("the selected font has no usable TrueType/CFF outlines and metrics".into()); + } + if font.os2().is_ok_and(|t| t.fs_type() & (0x0002 | 0x0004 | 0x0200) != 0) { + return Err("this font's embedding permissions prohibit editable outline embedding".into()); + } + let m = font.metrics(Size::unscaled(), LocationRef::default()); + if m.units_per_em == 0 || font.charmap().mappings().next().is_none() { + return Err("the selected font has no Unicode mapping or valid units per em".into()); + } + if font.charmap().mappings().take(65_535).count() >= 65_535 { + return Err("the selected font has too many Unicode mappings for one PDF font".into()); + } + if font.charmap().mappings().any(|(_, g)| g.to_u32() >= u32::from(m.glyph_count)) { + return Err("the selected font maps Unicode to a glyph outside its program".into()); + } + Ok(()) +} + +fn stream(doc: &mut Document, bytes: Vec) -> Object { + Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), bytes)))) +} + +/// Embed the complete font once and provide separate CIDs for Unicode aliases. /ToUnicode, +/// /CIDToGIDMap and widths agree, including supplementary characters, so reopening needs no +/// installed font. Keeping the full program also lets subsequent edits add other glyphs. +pub fn embed(doc: &mut Document, fonts: &mut Dict, bytes: &[u8], family: &str) -> Result { + validate(bytes)?; + let font = FontRef::new(bytes).map_err(|e| e.to_string())?; + let mappings: Vec<_> = font + .charmap() + .mappings() + .filter(|(_, gid)| gid.to_u32() > 0) + .filter_map(|(cp, gid)| char::from_u32(cp).map(|c| (c.to_string(), gid))) + .collect(); + embed_mapping(doc, fonts, bytes, family, &mappings) +} + +/// Explicit glyph mapping for shaped text. A CID can represent a contextual glyph or a +/// ligature, while its ToUnicode retains the logical cluster. The full source program stays exact. +pub fn embed_mapping( + doc: &mut Document, + fonts: &mut Dict, + bytes: &[u8], + family: &str, + mappings: &[(String, skrifa::GlyphId)], +) -> Result { + validate(bytes)?; + if mappings.len() > 65_534 || mappings.iter().any(|(s, _)| s.len() > 4096) { + return Err("shaped font mapping exceeds its budget".into()); + } + let font = FontRef::new(bytes).map_err(|e| e.to_string())?; + let cff = font.glyf().is_err(); + let loc = LocationRef::default(); + let m = font.metrics(Size::unscaled(), loc); + let scale = 1000.0 / f64::from(m.units_per_em); + let advances = font.glyph_metrics(Size::unscaled(), loc); + let mut cid_encoding = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapName /PCGlyphs def\n/CMapType 1 def\n1 begincodespacerange\n<0000> \nendcodespacerange\n", + ); + let mut cff_widths = std::collections::BTreeMap::new(); + let mut gids = vec![0, 0]; + let mut widths = Vec::with_capacity(mappings.len()); + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapName /PCUnicode def\n/CMapType 2 def\n1 begincodespacerange\n<0000> \nendcodespacerange\n", + ); + for (chunk_index, chunk) in mappings.chunks(100).enumerate() { + let _ = writeln!(cmap, "{} beginbfchar", chunk.len()); + let _ = writeln!(cid_encoding, "{} begincidchar", chunk.len()); + for (offset, (unicode, gid)) in chunk.iter().enumerate() { + let cid = u16::try_from(chunk_index.saturating_mul(100).saturating_add(offset).saturating_add(1)).map_err(|_| "font mapping limit")?; + let glyph = u16::try_from(gid.to_u32()).map_err(|_| "font glyph limit")?; + if glyph == 0 || u32::from(glyph) >= u32::from(m.glyph_count) { + return Err("font contains an unavailable shaped glyph".into()); + } + let width = advances.advance_width(*gid).unwrap_or(0.0) as f64 * scale; + if !width.is_finite() || !(0.0..=16_000.0).contains(&width) { + return Err("font contains an invalid advance".into()); + } + gids.extend_from_slice(&glyph.to_be_bytes()); + widths.push(Object::Real(width)); + cff_widths.insert(glyph, width); + let _ = writeln!(cid_encoding, "<{cid:04X}> {glyph}"); + let hex: String = unicode.encode_utf16().map(|u| format!("{u:04X}")).collect(); + let _ = writeln!(cmap, "<{cid:04X}> <{hex}>"); + } + cmap.push_str("endbfchar\n"); + cid_encoding.push_str("endcidchar\n"); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + cid_encoding.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + let name = family.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect::(); + if name.is_empty() || name.len() > 128 { + return Err("the font family name is invalid".into()); + } + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(&name)); + descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec()))); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Real(f64::from(m.italic_angle))); + descriptor.set(b"Ascent".to_vec(), Object::Real(f64::from(m.ascent) * scale)); + descriptor.set(b"Descent".to_vec(), Object::Real(f64::from(m.descent) * scale)); + descriptor.set(b"CapHeight".to_vec(), Object::Real(f64::from(m.cap_height.unwrap_or(m.ascent)) * scale)); + descriptor.set(b"StemV".to_vec(), Object::Int(80)); + let bounds = m.bounds.ok_or("font has no outline bounds")?; + descriptor.set( + b"FontBBox".to_vec(), + Object::Array([bounds.x_min, bounds.y_min, bounds.x_max, bounds.y_max].map(|v| Object::Real(f64::from(v) * scale)).to_vec()), + ); + // Reuse a full program registered on another page; source subset programs stay untouched. + let existing = doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { + Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)), + _ => None, + }); + let program = existing.unwrap_or_else(|| { + let mut d = Dict::new(); + d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); + if cff { + d.set(b"Subtype".to_vec(), Object::name("OpenType")); + } + d.set(b"PCFullFont".to_vec(), Object::Bool(true)); + Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, bytes.to_vec())))) + }); + descriptor.set(if cff { b"FontFile3".to_vec() } else { b"FontFile2".to_vec() }, program); + let desc = Object::Ref(doc.add(Object::Dict(descriptor))); + let mut system = Dict::new(); + system.set(b"Registry".to_vec(), Object::String(PdfString::literal(b"Adobe".to_vec()))); + system.set(b"Ordering".to_vec(), Object::String(PdfString::literal(b"Identity".to_vec()))); + system.set(b"Supplement".to_vec(), Object::Int(0)); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::name("Font")); + cid.set(b"Subtype".to_vec(), Object::name(if cff { "CIDFontType0" } else { "CIDFontType2" })); + cid.set(b"BaseFont".to_vec(), Object::name(&name)); + cid.set(b"CIDSystemInfo".to_vec(), Object::Dict(system)); + cid.set(b"FontDescriptor".to_vec(), desc); + if cff { + cid.set( + b"W".to_vec(), + Object::Array(cff_widths.into_iter().flat_map(|(gid, w)| [Object::Int(i64::from(gid)), Object::Array(vec![Object::Real(w)])]).collect()), + ); + } else { + cid.set(b"CIDToGIDMap".to_vec(), stream(doc, gids)); + cid.set(b"W".to_vec(), Object::Array(vec![Object::Int(1), Object::Array(widths)])); + } + let descendant = Object::Ref(doc.add(Object::Dict(cid))); + let mut top = Dict::new(); + top.set(b"Type".to_vec(), Object::name("Font")); + top.set(b"Subtype".to_vec(), Object::name("Type0")); + top.set(b"BaseFont".to_vec(), Object::name(&name)); + top.set(b"Encoding".to_vec(), if cff { stream(doc, cid_encoding.into_bytes()) } else { Object::name("Identity-H") }); + top.set(b"DescendantFonts".to_vec(), Object::Array(vec![descendant])); + top.set(b"ToUnicode".to_vec(), stream(doc, cmap.into_bytes())); + let mut index = 0usize; + while fonts.contains(format!("PCFull{index}").as_bytes()) { + index = index.saturating_add(1); + } + let resource = format!("PCFull{index}"); + fonts.set(resource.as_bytes().to_vec(), Object::Ref(doc.add(Object::Dict(top)))); + Ok(resource) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pdf::Metrics; + + #[test] + fn full_unicode_embedding_keeps_outlines_advances_and_roundtrip_codes() { + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let mut doc = Document::new_empty(); + let mut fonts = Dict::new(); + let name = embed(&mut doc, &mut fonts, bytes, "Inter").unwrap(); + let font = doc.dict(fonts.get(name.as_bytes()).unwrap()).unwrap(); + let m = Metrics::from_dict(&doc, &font); + let text = "Aé ΩЖ €"; + let codes = m.encode(text).unwrap(); + assert_eq!(m.decode(&codes), text); + assert_eq!(crate::pdf::embedded_sfnt(&doc, &font).unwrap(), bytes); + let sfnt = FontRef::new(bytes).unwrap(); + for (ch, (code, _)) in text.chars().zip(m.codes(&codes)) { + let gid = sfnt.charmap().map(ch).unwrap(); + let w = sfnt.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(gid).unwrap() as f64 + / f64::from(sfnt.metrics(Size::unscaled(), LocationRef::default()).units_per_em); + assert!((m.width(code) - w).abs() < 1e-8); + } + let before = doc.object_numbers().len(); + assert!(embed(&mut doc, &mut fonts, b"broken font", "Broken").is_err()); + assert!(embed_mapping(&mut doc, &mut fonts, bytes, "Inter", &[("X".into(), skrifa::GlyphId::new(65_535))]).is_err()); + assert_eq!(doc.object_numbers().len(), before); + let programs = || doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(); + assert_eq!(programs(), 1); + let mut second = Dict::new(); + embed(&mut doc, &mut second, bytes, "Inter").unwrap(); + assert_eq!(doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(), 1); + assert_eq!(face_key("ABCDEF+Droid-Sans-Fallback"), face_key("Droid Sans Fallback")); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 9390c988c..19d0d1daf 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -6,10 +6,12 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod craft; +pub mod embedding; mod encodings; pub mod pdf; mod script; mod shaping; +pub mod source_shaping; pub use craft::{ CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style, document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts, diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 412201a73..c418a03b8 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -57,6 +57,8 @@ pub struct Metrics { pub italic: bool, /// Bytes per code for writing new text (1 for simple fonts, else the codespace length). code_len: usize, + /// Valid single-character explicit mappings, preindexed once for large full CJK fonts. + explicit_reverse: HashMap, } fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -69,6 +71,110 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option { o.and_then(|o| doc.resolve(o).as_dict().cloned()) } +/// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export +/// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. +pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); + let descriptor = dict(doc, descendant.as_ref().unwrap_or(font).get(b"FontDescriptor"))?; + for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] { + let Some(object) = descriptor.get(key) else { continue }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { continue }; + let Ok(mut bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; + if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { + continue; + } + if face.charmap().mappings().next().is_some() { + return Some(bytes); + } + // Some PDF generators keep a Mac format-6 code→glyph cmap. egui accepts Unicode + // format-4/12 cmaps. Combine the PDF's Unicode meanings with the source glyph IDs; + // only this in-memory copy gets a new cmap, never the working PDF or a font asset. + let Ok(cmap) = face.cmap() else { continue }; + let metrics = Metrics::from_dict(doc, font); + if metrics.composite { + continue; + } + let mut mappings = std::collections::BTreeMap::new(); + for code in 0..=255 { + let Some(text) = metrics.unicode.text(code) else { continue }; + let Some(ch) = only_char(&text) else { continue }; + let Some(glyph) = cmap.map_codepoint(code).or_else(|| cmap.map_codepoint(code + 0xf000)) else { continue }; + let glyph = glyph.to_u32(); + if glyph > 0 { + mappings.entry(u32::from(ch)).or_insert(glyph); + } + } + if !mappings.is_empty() + && append_editor_cmap(&mut bytes, &mappings).is_some() + && skrifa::FontRef::new(&bytes).is_ok_and(|font| font.charmap().mappings().next().is_some()) + { + return Some(bytes); + } + } + None +} + +/// Replace an existing cmap directory entry with a bounded Unicode format-12 table. +/// Checked ranges prevent damaged sfnt offsets from indexing outside the source program. +fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap) -> Option<()> { + if mappings.len() > 65536 { + return None; + } + let count = u16::from_be_bytes(bytes.get(4..6)?.try_into().ok()?) as usize; + if count > 256 { + return None; + } + let mut entry = None; + let mut head = None; + for i in 0..count { + let at = 12 + i * 16; + let record = bytes.get(at..at + 16)?; + if record.get(..4)? == b"cmap" { + entry = Some(at); + } + if record.get(..4)? == b"head" { + head = Some(u32::from_be_bytes(record.get(8..12)?.try_into().ok()?) as usize); + } + } + let entry = entry?; + let mut cmap = Vec::with_capacity(28 + mappings.len() * 12); + cmap.extend_from_slice(&[0, 0, 0, 1, 0, 3, 0, 10, 0, 0, 0, 12, 0, 12, 0, 0]); + cmap.extend_from_slice(&(16_u32 + u32::try_from(mappings.len()).ok()? * 12).to_be_bytes()); + cmap.extend_from_slice(&0_u32.to_be_bytes()); + cmap.extend_from_slice(&u32::try_from(mappings.len()).ok()?.to_be_bytes()); + for (&ch, &gid) in mappings { + cmap.extend_from_slice(&ch.to_be_bytes()); + cmap.extend_from_slice(&ch.to_be_bytes()); + cmap.extend_from_slice(&gid.to_be_bytes()); + } + while !bytes.len().is_multiple_of(4) { + bytes.push(0); + } + let offset = u32::try_from(bytes.len()).ok()?; + let checksum = |data: &[u8]| { + data.chunks(4).fold(0_u32, |sum, c| { + let mut word = [0; 4]; + for (a, b) in word.iter_mut().zip(c) { + *a = *b; + } + sum.wrapping_add(u32::from_be_bytes(word)) + }) + }; + bytes.get_mut(entry + 4..entry + 8)?.copy_from_slice(&checksum(&cmap).to_be_bytes()); + bytes.get_mut(entry + 8..entry + 12)?.copy_from_slice(&offset.to_be_bytes()); + bytes.get_mut(entry + 12..entry + 16)?.copy_from_slice(&u32::try_from(cmap.len()).ok()?.to_be_bytes()); + bytes.extend_from_slice(&cmap); + if let Some(head) = head { + bytes.get_mut(head.checked_add(8)?..head.checked_add(12)?)?.fill(0); + let adjustment = 0xb1b0afba_u32.wrapping_sub(checksum(bytes)); + bytes.get_mut(head + 8..head + 12)?.copy_from_slice(&adjustment.to_be_bytes()); + } + Some(()) +} + fn descriptor_flags(doc: &Document, descriptor: &Dict) -> u32 { let value = descriptor.get(b"Flags").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) as i64; u32::try_from(value).unwrap_or(0) @@ -418,6 +524,7 @@ impl Metrics { bold: false, italic: false, code_len: 1, + explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(), } } @@ -444,6 +551,15 @@ impl Metrics { Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; m.unicode = unicode_map(doc, font, m.composite); + let mut reverse = HashMap::new(); + for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { + if let Some(ch) = only_char(text) + && m.shows(code, ch) + { + keep(&mut reverse, ch, code); + } + } + m.explicit_reverse = reverse; m } @@ -640,20 +756,9 @@ impl Metrics { // checked one by one; each range is asked for the wanted characters it can show. let wanted: HashSet = text.chars().collect(); let mut reverse: HashMap = HashMap::new(); - for (&code, (_, t)) in &self.unicode.explicit { - if let Some(c) = only_char(t) - && wanted.contains(&c) - && self.shows(code, c) - { - keep(&mut reverse, c, code); - } - } - for (&code, t) in &self.unicode.encoding { - if let Some(c) = only_char(t) - && wanted.contains(&c) - && self.shows(code, c) - { - keep(&mut reverse, c, code); + for &ch in &wanted { + if let Some(&code) = self.explicit_reverse.get(&ch) { + keep(&mut reverse, ch, code); } } for range in &self.unicode.ranges { @@ -965,6 +1070,61 @@ mod tests { d } + #[test] + fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { + use pdfcraft_cos::Stream; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor.clone()))]); + assert_eq!(embedded_sfnt(&doc, &simple).unwrap(), bytes); + let composite = font(&mut doc, vec![("DescendantFonts", Object::Array(vec![Object::Dict(simple.clone())]))]); + assert_eq!(embedded_sfnt(&doc, &composite).unwrap(), bytes); + let broken = doc.add(Object::Stream(Stream::flate(Dict::new(), b"broken font"))); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(broken)); + let broken = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + assert!(embedded_sfnt(&doc, &broken).is_none()); + assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); + } + + #[test] + fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let original = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(original).unwrap(); + let expected = face.charmap().map('Ω').unwrap(); + let mut subset = original.to_vec(); + let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; + let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); + let mut cmap = vec![0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 12]; + cmap.extend_from_slice(&[0, 6, 0, 12, 0, 0, 0, 65, 0, 1]); + cmap.extend_from_slice(&u16::try_from(expected.to_u32()).unwrap().to_be_bytes()); + let offset = subset.len() as u32; + subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); + subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); + subset.extend_from_slice(&cmap); + let face = skrifa::FontRef::new(&subset).unwrap(); + assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); + assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfchar <41> <03A9> endbfchar"))); + let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); + let editor = embedded_sfnt(&doc, &source).unwrap(); + let face = skrifa::FontRef::new(&editor).unwrap(); + assert_eq!(face.charmap().map('Ω'), Some(expected), "Unicode selects the original glyph"); + let Object::Stream(source) = doc.resolve(&Object::Ref(program)).as_ref().clone() else { + panic!("source stream"); + }; + assert_eq!(source.decoded().unwrap(), subset, "only an in-memory editor copy is adapted"); + let mut broken = vec![0; 16]; + assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); + } + fn metrics_with_flags(flags: Option) -> Metrics { let mut doc = Document::new_empty(); let mut descriptor = Dict::new(); diff --git a/crates/fonts/src/source_shaping.rs b/crates/fonts/src/source_shaping.rs new file mode 100644 index 000000000..b6b28ca69 --- /dev/null +++ b/crates/fonts/src/source_shaping.rs @@ -0,0 +1,76 @@ +//! Shaping an explicitly selected source/document face, with OpenType features and UAX #9. +use harfrust::{BufferClusterLevel, Direction, ShapeOptions, ShaperData, UnicodeBuffer}; +use skrifa::raw::TableProvider; +use skrifa::{FontRef, GlyphId}; +use unicode_bidi::{Level, ParagraphBidiInfo}; + +#[derive(Clone, Debug)] +pub struct ShapedGlyph { + pub id: GlyphId, + pub position: [f64; 2], + pub text: String, +} +#[derive(Clone, Debug)] +pub struct ShapedRun { + pub glyphs: Vec, + pub advance: f64, +} + +/// Complex scripts/marks require shaping instead of independently encoding characters. +pub fn required(text: &str) -> bool { + text.chars() + .any(|c| matches!(u32::from(c), 0x0300..=0x036f | 0x0590..=0x0fff | 0x1780..=0x17ff | 0x200c..=0x200f | 0x202a..=0x202e | 0x2066..=0x2069)) +} + +pub fn shape(bytes: &[u8], text: &str) -> Result { + if text.len() > 262_144 || text.contains(['\n', '\r']) { + return Err("shape one bounded text row at a time".into()); + } + if text.is_empty() { + return Ok(ShapedRun { glyphs: Vec::new(), advance: 0.0 }); + } + let face = FontRef::new(bytes).map_err(|e| e.to_string())?; + if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0fff | 0x1780..=0x17ff)) && face.gsub().is_err() { + return Err("the source subset has no contextual substitution tables; select its full font".into()); + } + let data = ShaperData::new(&face); + let shaper = data.shaper(&face).build(); + let em = f64::from(shaper.units_per_em().max(1)); + let base = if unicode_bidi::get_base_direction(text) == unicode_bidi::Direction::Rtl { Level::rtl() } else { Level::ltr() }; + let bidi = ParagraphBidiInfo::new(text, Some(base)); + let (levels, runs) = bidi.visual_runs(0..text.len()); + let mut glyphs = Vec::new(); + let mut x = 0.0; + for run in runs { + let Some(input) = text.get(run.clone()) else { continue }; + let rtl = levels.get(run.start).is_some_and(Level::is_rtl); + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(input); + buffer.set_cluster_level(BufferClusterLevel::MonotoneGraphemes); + buffer.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + buffer.guess_segment_properties(); + let output = shaper.shape(buffer, ShapeOptions::new()); + let mut starts: Vec<_> = output.glyph_infos().iter().map(|g| g.cluster as usize).collect(); + starts.sort_unstable(); + starts.dedup(); + let mut seen = std::collections::HashSet::new(); + for (g, p) in output.glyph_infos().iter().zip(output.glyph_positions()) { + if g.glyph_id == 0 { + return Err("the full font lacks a required contextual glyph; choose the complete source face".into()); + } + let start = g.cluster as usize; + let end = starts.get(starts.partition_point(|i| *i <= start)).copied().unwrap_or(input.len()); + let logical = if seen.insert(start) { input.get(start..end).unwrap_or_default().to_string() } else { String::new() }; + glyphs.push(ShapedGlyph { + id: GlyphId::new(g.glyph_id), + position: [x + f64::from(p.x_offset) / em, f64::from(p.y_offset) / em], + text: logical, + }); + x += f64::from(p.x_advance) / em; + } + } + if !x.is_finite() || glyphs.len() > 65_534 { + return Err("shaped row exceeds its geometry budget".into()); + } + Ok(ShapedRun { glyphs, advance: x }) +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8e4f03998..f70c1695e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -392,6 +392,7 @@ pub struct DocView { /// line being edited. pub(crate) edit_lines: HashMap)>, pub line_editor: Option, + pub(crate) text_background: crate::edit_text_ui::TextBackground, /// Edit text & images: the images per page (by document generation), and the selected one. pub(crate) edit_images: HashMap)>, pub(crate) objects: crate::object_ui::ObjectSelection, @@ -628,6 +629,7 @@ impl DocView { pending_edit: None, edit_lines: HashMap::new(), line_editor: None, + text_background: Default::default(), edit_images: HashMap::new(), objects: Default::default(), image_selection: None, @@ -768,7 +770,7 @@ impl DocView { } pub fn render_pending(&self) -> bool { - !self.last_queue.is_empty() + !self.last_queue.is_empty() || self.text_background.pending() } /// Re-render every page and text layer (the document's appearance changed, e.g. a layer @@ -1320,6 +1322,7 @@ impl DocView { self.waiting_since.clear(); // Signature previews own a separate renderer and image/background textures. self.signature_drag = Default::default(); + self.text_background = Default::default(); if !self.last_queue.is_empty() { pool.set_queue(Vec::new()); self.last_queue.clear(); @@ -2303,6 +2306,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.prepare.selected = None; } view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); + view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), scale); let added = doc.added.clone(); let doc_links = if tool == QuickTool::Link { doc.links.clone() } else { Vec::new() }; if editing_content { @@ -2504,6 +2508,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } + view.text_background.paint(painter, &xf, i, view.line_editor.as_ref()); painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); // Comments: tools, selection, moving and resizing come before text selection. @@ -2625,7 +2630,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view) }; - let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view); + let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, i, info, view); + let on_objects = + !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view); let on_content = !on_objects && editing_content && can_modify && { crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view) || tool == QuickTool::AddText @@ -2649,9 +2656,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { l } }; - // Images first (they can sit under text boxes' corners); then paragraphs. - crate::edit_text_ui::image_input(ui, &resp, &xf, i, info, &images, view, &mut image_action) - || crate::edit_text_ui::page_input(ui, &resp, &xf, i, doc, &lines, view) + // Editable text takes precedence over background/overlapping artwork. Both + // inventories are painted every frame, even when text owns the pointer. + let text_used = crate::edit_text_ui::page_input(ui, &resp, &xf, i, doc, &lines, view, !image_owns_pointer); + let image_used = + crate::edit_text_ui::image_input(ui, &resp, &xf, i, info, &images, view, &mut image_action, image_owns_pointer || !text_used); + text_used || image_used }; let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index bf807146a..4cd8aaea4 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -4,6 +4,9 @@ //! it to a new width. Click an image to select it: drag to move, drag a corner to resize (keeping //! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it. +use std::collections::BTreeMap; +use std::sync::Arc; + use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke}; use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; use pdfcraft_render::DocInfo; @@ -12,6 +15,82 @@ use crate::canvas::{DocView, PageXform}; const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// A cached, read-only page background. The source glyphs are removed before rasterization; +/// colour, pictures and unselected text beneath the editor remain visible. +#[derive(Default)] +pub(crate) struct TextBackground { + key: Option<(u64, usize, usize)>, + renderer: Option, + texture: Option, + tag: Option, + received: Option, + draft_key: Option<(String, pdfcraft_engine::BlockStyle)>, + draft_rect: Option<[f64; 4]>, +} + +impl TextBackground { + pub fn pending(&self) -> bool { + self.renderer.is_some() && self.received != self.tag + } + + pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, editor: Option<&LineEditor>, scale: f32) { + let key = editor.map(|ed| (doc.edit_generation(), ed.page, ed.block)); + if self.key != key { + *self = Self { key, ..Default::default() }; + if let Some((_, page, block)) = key { + self.renderer = doc.text_edit_preview(page, block).ok().map(|p| p.renderer()); + } + } + if let Some(ed) = editor { + let draft_key = (ed.text.clone(), ed.style()); + if self.draft_key.as_ref() != Some(&draft_key) { + self.draft_rect = doc.text_edit_layout(ed.page, ed.block, &ed.text, &draft_key.1).ok().and_then(|layout| layout.draft_rect); + self.draft_key = Some(draft_key); + } + } + let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; + let Some(p) = doc.info.pages.get(page) else { return }; + let max_side = ctx.input(|i| i.max_texture_side) as f32; + let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0)); + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + self.tag = Some(tag); + } + if let Some(out) = renderer.try_recv() { + if out.error.is_some() { + self.renderer = None; + self.texture = None; + return; + } + if out.request.tag == tag { + self.texture = Some(ctx.load_texture( + "text-editor-background", + crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba), + egui::TextureOptions::LINEAR, + )); + self.received = Some(tag); + } + } + if self.received != Some(tag) { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + pub fn ready(&self, page: usize, block: usize) -> bool { + self.key.is_some_and(|(_, p, b)| (p, b) == (page, block)) && self.texture.is_some() + } + + pub fn paint(&self, painter: &egui::Painter, xf: &PageXform, page: usize, editor: Option<&LineEditor>) { + let Some(_) = editor.filter(|ed| ed.page == page && self.ready(page, ed.block)) else { return }; + let Some(tex) = &self.texture else { return }; + // Font metrics can underestimate the actual glyph ink. Paint the complete textless + // page so overhanging glyphs cannot remain outside the estimated source rectangle. + let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + xf.paint_image(&painter, tex.id(), 0.0, 0.0, 1.0, 1.0); + } +} + /// How many faint Edit-text outlines are drawn per page per frame. CAD plots can draw /// ~1000 tiny images on one page; stroking all of them every frame (plus a hover query /// per box) stalls the UI. Past this, only the selected and hovered boxes are outlined: @@ -21,6 +100,8 @@ const MAX_OUTLINES: usize = 250; /// A paragraph being edited. #[derive(Clone, Debug, PartialEq)] pub struct LineEditor { + pub selection: Option>, + pub font_resource: Option, pub page: usize, pub block: usize, pub text: String, @@ -30,8 +111,11 @@ pub struct LineEditor { /// stays attached while the page is zoomed, scrolled, or rotated. source_rect: [f32; 4], multiline: bool, - /// How far the editor box may grow to the right (screen pixels): a single-line paragraph - /// rewraps growing to the page's edge, a multi-line one keeps its width. + source: Option<(pdfcraft_engine::TextBlock, Vec)>, + pub(crate) fonts: BTreeMap, + /// The draft extends outside the source container; its frame follows the live draft bounds. + pub overflow: bool, + /// The initial source container width in screen pixels. max_width: f32, /// Current screen-space size. Recomputed from the current page transform before painting. size: f32, @@ -91,8 +175,7 @@ impl LineEditor { self.text != self.original || self.look != self.look0 || self.extras != self.extras0 } - /// How far the box may grow to the right, when the paragraph is a single line (a multi-line - /// paragraph rewraps to its own width and the box doesn't grow). + /// Extra width permitted by the editor. Source containers now remain fixed. pub fn growth(&self) -> Option { (self.max_width > self.rect.width()).then_some(self.max_width) } @@ -105,7 +188,8 @@ impl LineEditor { /// The formatting the panel changed. pub fn style(&self) -> pdfcraft_engine::BlockStyle { let (l, o) = (&self.look, &self.look0); - pdfcraft_engine::BlockStyle { + 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)), size: (l.size != o.size).then_some(l.size), color: (l.color != o.color).then_some(l.color), @@ -116,11 +200,34 @@ impl LineEditor { char_spacing: (self.extras.char_spacing != self.extras0.char_spacing).then_some(self.extras.char_spacing), scale: (self.extras.scale != self.extras0.scale).then_some(self.extras.scale), ..Default::default() + }; + if let Some(range) = &self.selection + && (style.family.is_some() + || style.resource.is_some() + || style.size.is_some() + || style.color.is_some() + || style.underline.is_some() + || style.char_spacing.is_some() + || style.scale.is_some()) + { + style.ranges.push(pdfcraft_engine::RangeStyle { + range: range.clone(), + family: style.family.take(), + resource: style.resource.take(), + size: style.size.take(), + color: style.color.take(), + underline: style.underline.take(), + char_spacing: style.char_spacing.take(), + scale: style.scale.take(), + }); } + style } /// After applying formatting: the paragraph now has it. pub fn applied(&mut self) { + self.selection = None; + self.font_resource = None; self.look0 = self.look.clone(); self.extras0 = self.extras; self.original = self.text.clone(); @@ -128,9 +235,12 @@ impl LineEditor { } /// Adopt the rewritten paragraph's current geometry before the next overlay frame. - pub(crate) fn refresh_source(&mut self, block: &pdfcraft_engine::TextBlock) { + pub(crate) fn refresh_source(&mut self, block: &pdfcraft_engine::TextBlock, lines: Vec) { self.source_rect = block.rect.map(|v| v as f32); - self.multiline = block.lines.len() > 1; + self.multiline = block.rows.len() > 1; + self.source = Some((block.clone(), lines)); + self.text = block.editor_text.clone(); + self.original = self.text.clone(); } } @@ -209,6 +319,20 @@ pub struct ImageSelection { drag: Option<(Pos2, Option)>, } +/// Once a selected corner is grabbed, text under it cannot take over the gesture. The same +/// applies after the pointer leaves the image/page while dragging. +pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool { + let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; + if selection.drag.is_some() { + return true; + } + let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; + let Some(rect) = image.visible_rect else { return false }; + let b = xf.user_rect(info, page, rect.map(|v| v as f32)); + let p = ui.input(|i| if i.pointer.primary_down() { i.pointer.press_origin() } else { i.pointer.hover_pos() }); + p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().any(|c| c.distance(p) < 8.0)) +} + /// What a drag on a paragraph box takes hold of. #[derive(Clone, Copy, Debug, PartialEq)] enum Grip { @@ -288,57 +412,76 @@ pub(crate) fn image_input( images: &[pdfcraft_engine::PageImage], view: &mut DocView, action: &mut Option, + allow_interaction: bool, ) -> bool { - let boxes: Vec = images.iter().map(|im| xf.user_rect(info, page, im.rect.map(|v| v as f32))).collect(); + let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); let painter = ui.painter(); let hover_pos = ui.input(|i| i.pointer.hover_pos()); + let image_contains = |index: usize, point: Pos2| { + let (x, y) = xf.screen_to_view(point); + let Some(p) = info.pages.get(page) else { return false }; + let user = p.view_to_user(x, y); + images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) + }; let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); + let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); // Few images: faint outlines for all of them, as before. Many: only the selected and // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. + // Fully clipped placements (empty visible frames) get no outline. let faint_all = boxes.len() <= MAX_OUTLINES; if faint_all { - for b in &boxes { + for b in boxes.iter().filter(|b| b.is_positive()) { painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); } } - let hovered_box = hover_pos.and_then(|p| boxes.iter().rposition(|b| b.contains(p))); + let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); if !faint_all { - if let Some(i) = selected { - painter.rect_stroke(boxes[i], CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); - } - if let Some(h) = hovered_box.filter(|h| Some(*h) != selected) { - painter.rect_stroke(boxes[h], CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + for i in selected.into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { + if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) { + painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } } } for i in selected.into_iter().chain(hovered_box).collect::>() { - let b = boxes[i]; + let Some(b) = boxes.get(i).copied().filter(|b| b.is_positive()) else { continue }; if selected == Some(i) { - painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + // The selected frame and handles are drawn below (or the drag preview instead). + if !dragging { + painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + } } else { painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } // The selected image: frame, corner handles, dragging. - if let Some(i) = selected { + if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; - for c in corners { - painter.rect( - Rect::from_center_size(c, egui::vec2(8.0, 8.0)), - CornerRadius::ZERO, - Color32::WHITE, - Stroke::new(1.0, ACCENT), - egui::StrokeKind::Middle, - ); + let handles = |rect: Rect| { + for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + painter.rect( + Rect::from_center_size(c, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + }; + if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + handles(b); + } + if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { + ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); } let origin = ui.input(|i| i.pointer.press_origin()); - if resp.drag_started() + if allow_interaction + && resp.drag_started() && let Some(o) = origin { let corner = corners.iter().position(|c| c.distance(o) < 8.0); - if corner.is_some() || b.contains(o) { + if corner.is_some() || image_contains(i, o) { let opposite = corner.map(|k| corners[3 - k]); if let Some(s) = view.image_selection.as_mut() { s.drag = Some((o, opposite)); @@ -352,38 +495,75 @@ pub(crate) fn image_input( // Resize from the opposite corner, keeping the aspect ratio. Some(fixed) => { let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); - let k = ((p.x - fixed.x).abs() / w0).max((p.y - fixed.y).abs() / h0).max(0.05); + // Use the axis with the larger relative pointer change. Taking the maximum + // absolute size freezes shrinking when the other axis hasn't moved. Measure + // from the press point so grabbing a handle off-centre cannot cause a jump. + let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; + let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; + let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + delta).max(0.05); let (w, h) = (w0 * k, h0 * k); - let x = if p.x < fixed.x { fixed.x - w } else { fixed.x }; - let y = if p.y < fixed.y { fixed.y - h } else { fixed.y }; + let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; + let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) } None => b.translate(p - start), }; - painter.rect_stroke(preview, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); - if resp.drag_stopped() { + // Move/resize the full placement by the visible frame's change. The engine + // transforms an isolated crop together with its image, preserving its contents. + let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); + // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the + // placement's scale instead of producing inf/NaN. + let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; + let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); + let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); + let visible = preview.intersect(xf.rect); + if visible.is_positive() { + painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + handles(preview); + } + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { if let Some(s) = view.image_selection.as_mut() { s.drag = None; } if preview != b { view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, preview)) }); + Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); } } + ui.ctx().set_cursor_icon(match opposite { + Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, + Some(_) => egui::CursorIcon::ResizeNwSe, + None => egui::CursorIcon::Grabbing, + }); return true; } - if ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) && !ui.ctx().egui_wants_keyboard_input() { + if allow_interaction + && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) + && !ui.ctx().egui_wants_keyboard_input() + { view.image_selection = None; view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); return true; } } + if !allow_interaction { + return false; + } let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; - let Some(hit) = boxes.iter().rposition(|b| b.contains(p)) else { return false }; + let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else { return false }; if selected != Some(hit) { painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } - ui.ctx().set_cursor_icon(if selected == Some(hit) { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }); + let Some(b) = boxes.get(hit).copied() else { return false }; + let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0); + ui.ctx().set_cursor_icon(match (selected == Some(hit), corner) { + (true, Some(0 | 3)) => egui::CursorIcon::ResizeNwSe, + (true, Some(_)) => egui::CursorIcon::ResizeNeSw, + (true, None) => egui::CursorIcon::Move, + _ => egui::CursorIcon::PointingHand, + }); if resp.clicked() || resp.secondary_clicked() { view.image_selection = Some(ImageSelection { page, index: hit, drag: None }); } @@ -424,6 +604,7 @@ pub(crate) fn image_input( /// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns /// `true` when the pointer was used. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, @@ -432,6 +613,7 @@ pub(crate) fn page_input( doc: &pdfcraft_engine::Document, lines: &[pdfcraft_engine::TextBlock], view: &mut DocView, + allow_interaction: bool, ) -> bool { let info = &doc.info; if view.block_drag.is_some_and(|d| d.generation != doc.edit_generation()) { @@ -442,13 +624,13 @@ pub(crate) fn page_input( let painter = ui.painter(); let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block); for (i, b) in boxes.iter().enumerate() { - if active == Some(i) { - painter.rect_filled(b.expand(1.0), CornerRadius::same(2), ACCENT.gamma_multiply(0.08)); - painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - } else { + if active != Some(i) { painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); } } + if !allow_interaction { + return false; + } // The width handle needs the page upright (or upside down): on a quarter-turned page the // screen's horizontal is the paragraph's vertical. let upright = info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0); @@ -523,21 +705,29 @@ pub(crate) fn page_input( } ui.ctx().set_cursor_icon(if on_edge { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text }); if resp.clicked() { + view.image_selection = None; let l = &lines[hit]; + let source_lines = doc.text_lines(page); + let text = l.editor_text.clone(); // Screen pixels per point, from the box's width. let scale = (boxes[hit].width() - 4.0) / ((l.rect[2] - l.rect[0]).max(1.0) as f32); // How far the editor box may grow: a single-line paragraph's rewrite grows to the // page's right edge, a multi-line paragraph rewraps to its own width. let right = xf.rect.right().min(view.viewport_rect().right()) - 6.0; - let max_width = if l.lines.len() == 1 { (right - boxes[hit].left()).max(boxes[hit].width()) } else { boxes[hit].width() }; + let max_width = if l.rows.len() == 1 { (right - boxes[hit].left()).max(boxes[hit].width()) } else { boxes[hit].width() }; view.line_editor = Some(LineEditor { + selection: None, + font_resource: None, page, block: hit, - text: l.text.clone(), - original: l.text.clone(), + text: text.clone(), + original: text, rect: boxes[hit], source_rect: l.rect.map(|v| v as f32), - multiline: l.lines.len() > 1, + multiline: l.rows.len() > 1, + source: Some((l.clone(), source_lines)), + fonts: install_source_fonts(ui.ctx(), doc, page), + overflow: false, max_width, size: (l.size as f32 * scale).clamp(8.0, 72.0), focus: true, @@ -550,56 +740,279 @@ pub(crate) fn page_input( true } +/// One TextEdit lays out all source runs; its caret and selection span the whole region. +pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::Document, page: usize) -> BTreeMap { + let programs = doc.text_editor_fonts(page); + let mut definitions = ctx.fonts(|f| f.definitions().clone()); + // Only the current editor's document fonts live in the atlas. Never accumulate the + // programs of previously opened files or add them to the interface's default families. + definitions.font_data.retain(|name, _| !name.starts_with("pdf-source-")); + definitions.families.retain(|family, _| !matches!(family, FontFamily::Name(name) if name.starts_with("pdf-source-"))); + let mut families = BTreeMap::new(); + for (index, program) in programs.iter().enumerate() { + let name = format!("pdf-source-{index}"); + definitions.font_data.insert(name.clone(), Arc::new(egui::FontData::from_owned(program.bytes.clone()))); + families.insert(program.resource.clone(), name); + } + for program in &programs { + let Some(name) = families.get(&program.resource) else { continue }; + // Word-created PDFs often split one face into several subsets. Use all of the + // document's matching subsets before falling back for newly inserted characters. + let mut stack = vec![name.clone()]; + stack.extend( + programs + .iter() + .filter(|p| { + pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face) + && p.resource != program.resource + }) + .filter_map(|p| families.get(&p.resource).cloned()), + ); + if let Some(fallback) = definitions.families.get(&FontFamily::Proportional) { + stack.extend(fallback.iter().cloned()); + } + definitions.families.insert(FontFamily::Name(name.clone().into()), stack); + } + if !families.is_empty() { + ctx.set_fonts(definitions); + } + families +} + +fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> egui::text::LayoutJob { + let mut job = egui::text::LayoutJob::default(); + // An unchanged PDF row already has an authoritative line break. Do not rewrap it + // because font rounding or a fallback face measures a fraction of a pixel wider. + job.wrap.max_width = if text == ed.original && ed.look == ed.look0 && ed.extras == ed.extras0 { f32::INFINITY } else { width }; + if let Some((block, lines)) = &ed.source { + let lefts: Vec<_> = block.rows.iter().filter_map(|r| r.first().and_then(|i| lines.get(*i)).map(|l| l.rect[0])).collect(); + let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min); + let centered = block.rows.len() > 1 + && left_span > block.size * 0.5 + && block.rows.iter().all(|row| { + let rect = row + .iter() + .filter_map(|i| lines.get(*i)) + .map(|l| l.rect) + .reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]); + rect.is_some_and(|r| ((r[0] + r[2] - block.rect[0] - block.rect[2]) / 2.0).abs() < block.size) + }); + if centered { + job.halign = egui::Align::Center; + } + for (range, index) in block.edited_spans(text) { + 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 { + look.family = ed.look.family; + } + if ed.look.bold != ed.look0.bold { + look.bold = ed.look.bold; + } + if ed.look.italic != ed.look0.italic { + look.italic = ed.look.italic; + } + if ed.look.color != ed.look0.color { + look.color = ed.look.color; + } + if ed.look.size != ed.look0.size { + 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 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()); + } + let native_width = ui.fonts_mut(|f| f.layout_no_wrap(line.text.clone(), font.clone(), Color32::BLACK).size().x); + let target_width = ((line.rect[2] - line.rect[0]) * look.size / line.size.max(0.01)) as f32 * scale; + let spacing = if original_face { (target_width - native_width) / line.text.chars().count().saturating_sub(1).max(1) as f32 } else { 0.0 }; + job.append( + text.get(range).unwrap_or_default(), + 0.0, + egui::TextFormat { + font_id: font, + extra_letter_spacing: spacing + ed.extras.char_spacing as f32 * scale, + color: color32(look.color), + italics: look.italic && !embedded, + ..Default::default() + }, + ); + } + } else { + job.append( + text, + 0.0, + egui::TextFormat { + font_id: editor_font(&ed.look, ed.size), + color: color32(ed.look.color), + italics: ed.look.italic, + ..Default::default() + }, + ); + } + job +} + +/// Align the editable galley's baselines to the source rows instead of egui's interface +/// leading. Glyphs, meshes, caret and selection receive the same transform. +fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> Arc { + let mut job = editor_job(ui, ed, text, scale, width); + let centered = job.halign == egui::Align::Center; + job.halign = egui::Align::LEFT; + let mut galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + let Some((block, lines)) = &ed.source else { return galley }; + let sources: Vec<_> = block + .rows + .iter() + .filter_map(|row| { + let first = row.first().and_then(|i| lines.get(*i))?; + let rect = row + .iter() + .filter_map(|i| lines.get(*i)) + .map(|l| l.rect) + .reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])?; + Some((first.baseline()[1], rect)) + }) + .collect(); + let Some((first_baseline, first_rect)) = sources.first().copied() else { return galley }; + let leading = if ed.extras.line_spacing > 0.0 { + ed.extras.line_spacing * ed.look.size + } else { + let fallback = if block.lines.len() == 1 && !text.contains('\n') { + block.lines.first().and_then(|i| lines.get(*i)).map_or(block.size * 1.2, |line| line.wrap_leading()) + } else { + block.size * 1.5 + }; + sources.windows(2).map(|r| (r[0].0 - r[1].0).abs()).rfind(|v| *v > 0.0).unwrap_or(fallback) + }; + let g = Arc::make_mut(&mut galley); + let mut bounds = Rect::NOTHING; + let mut meshes = Rect::NOTHING; + for (index, placed) in g.rows.iter_mut().enumerate() { + let (last_baseline, last_rect) = sources.last().copied().unwrap_or((first_baseline, first_rect)); + let (baseline, source_rect) = sources + .get(index) + .copied() + .unwrap_or((last_baseline - index.saturating_add(1).saturating_sub(sources.len()) as f64 * leading, last_rect)); + let changed_size = ed.look.size != ed.look0.size; + let ratio = if changed_size { ed.look.size / ed.look0.size.max(0.01) } else { 1.0 }; + let ascent = (source_rect[3] - sources.get(index).map_or(last_baseline, |s| s.0)) as f32 * scale * ratio as f32; + let descent = (sources.get(index).map_or(last_baseline, |s| s.0) - source_rect[1]) as f32 * scale * ratio as f32; + let base_y = (block.rect[3] - baseline) as f32 * scale; + let row = Arc::make_mut(&mut placed.row); + placed.pos.y = base_y - ascent; + placed.pos.x = if centered { (width - row.size.x) / 2.0 } else { (source_rect[0] - block.rect[0]) as f32 * scale }; + for glyph in &mut row.glyphs { + let dy = ascent - glyph.pos.y; + glyph.pos.y = ascent; + glyph.font_ascent = ascent; + glyph.font_height = ascent + descent; + glyph.line_height = ascent + descent; + if !glyph.uv_rect.is_nothing() { + for vertex in row.visuals.mesh.vertices.iter_mut().skip(glyph.first_vertex as usize).take(4) { + vertex.pos.y += dy; + } + } + } + row.size.y = (ascent + descent).max(1.0); + row.visuals.mesh_bounds = row.visuals.mesh.vertices.iter().fold(Rect::NOTHING, |r, v| r.union(Rect::from_min_max(v.pos, v.pos))); + bounds = bounds.union(Rect::from_min_size(placed.pos, row.size)); + meshes = meshes.union(row.visuals.mesh_bounds.translate(placed.pos.to_vec2())); + } + g.rect = Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(bounds.right().max(0.0), bounds.bottom().max(0.0))); + g.mesh_bounds = meshes; + galley +} + /// The inline editor; returns the edit once the text is applied. pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { // Clicks outside the document (the Format text panel) keep the paragraph open. let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); let page = view.line_editor.as_ref()?.page; let xf = view.page_xform(page)?; - let viewport_right = view.viewport_rect().right(); let hold = view.objects.hold_editor; + let background_ready = view.line_editor.as_ref().is_some_and(|ed| view.text_background.ready(ed.page, ed.block)); + let draft_rect = view.text_background.draft_rect.map(|r| xf.user_rect(info, page, r.map(|v| v as f32))); let ed = view.line_editor.as_mut()?; if hold { ed.focus = true; } // Reproject the source box every frame. The page may have been zoomed, scrolled or rotated // while the format panel was open. - ed.rect = xf.user_rect(info, ed.page, ed.source_rect).expand(2.0); - let right = xf.rect.right().min(viewport_right) - 6.0; - ed.max_width = if ed.multiline { ed.rect.width() } else { (right - ed.rect.left()).max(ed.rect.width()) }; - let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(1.0)).max(0.01); - ed.size = (ed.look.size as f32 * scale).clamp(8.0, 72.0); + // A font definition becomes active at the start of the next egui pass. Keep the source + // raster visible until then instead of flashing an unrelated interface face. + if !ctx.fonts(|f| ed.fonts.values().all(|name| f.definitions().families.contains_key(&FontFamily::Name(name.clone().into())))) { + ctx.request_repaint(); + return None; + } + ed.rect = xf.user_rect(info, ed.page, ed.source_rect); + ed.max_width = ed.rect.width(); + let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(0.01)).max(0.01); + ed.size = (ed.look.size as f32 * scale).clamp(1.0, 300.0); let font = editor_font(&ed.look, ed.size); let text_color = color32(ed.look.color); let mut done = None; egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( ctx, |ui| { - // The box follows the text as you type: as wide as the longest drafted line needs - // (up to what the rewrite allows), so new content grows the box instead of wrapping - // inside the old one. - let mut width = ed.rect.width().max(120.0); - if ed.max_width > width { - let needed = ui.fonts_mut(|f| { - ed.text.lines().map(|l| f.layout_no_wrap(l.to_owned(), font.clone(), text_color).size().x).fold(0.0_f32, f32::max) - }) + 8.0; - width = width.max(needed).min(ed.max_width); - } + // The editor opens at the source size. Once text changes, show the real draft + // extent: single rows grow toward the page edge, paragraphs rewrap at their + // source width, and the visible frame grows/shrinks vertically with their rows. + let single_run = ed.source.as_ref().is_some_and(|(block, _)| block.lines.len() == 1); + let width = if !ed.multiline && single_run && !ed.text.contains('\n') && ed.has_changes() { + let job = editor_job(ui, ed, &ed.text, scale, f32::INFINITY); + let natural = ui.fonts_mut(|f| f.layout_job(job)).size().x; + natural.max(ed.size * 0.4).min((xf.rect.right() - ed.rect.left() - 36.0 * scale).max(ed.rect.width())) + } else { + ed.rect.width().max(1.0) + }; + ui.set_width(width); + let measured = editor_galley(ui, ed, &ed.text, scale, width); + ed.overflow = ed.has_changes() && (measured.rect.width() > ed.rect.width() + 1.5 || measured.rect.height() > ed.rect.height() + 1.5); + let visible = Rect::from_min_size(ed.rect.min, measured.rect.size().max(egui::vec2(1.0, 1.0))); + // Unsupported source programs use an interface fallback whose glyph advances + // can differ from the PDF's. Keep every visible row inside the growing frame. + let live = draft_rect.map_or(visible, |bounds| bounds.union(visible)); + ui.painter().rect_stroke(live.expand(2.0), CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); // White, unless the text is too pale to read on white (white text from a coloured // banner, #913): then a dark box, with a caret that shows on it. - let fill = editor_fill(text_color); - egui::Frame::NONE.fill(fill).stroke(Stroke::new(1.5, ACCENT)).inner_margin(egui::Margin::symmetric(2, 0)).show(ui, |ui| { - ui.visuals_mut().text_cursor.stroke.color = caret_color_on(fill); - let rows = ed.text.lines().count().max(1); - let r = ui.add( - egui::TextEdit::multiline(&mut ed.text) - .id(egui::Id::new("edit-text-line-input")) - .font(font.clone()) - .text_color(text_color) - .frame(egui::Frame::NONE) - .desired_width(width) - .desired_rows(rows), - ); + let fill = if background_ready { Color32::TRANSPARENT } else { editor_fill(text_color) }; + // Until the background worker finishes, cover the entire source box, including + // lines the draft no longer occupies. Shrinking the text must never expose a copy. + if !background_ready { + ui.painter().rect_filled(ed.rect.expand(1.0), CornerRadius::ZERO, fill); + } + egui::Frame::NONE.fill(fill).show(ui, |ui| { + // A textless PDF preview keeps its real page background through the transparent editor. + // The temporary opaque cover uses the shared caret-contrast rule. + ui.visuals_mut().text_cursor.stroke.color = + if background_ready { if luminance(text_color) < 0.5 { Color32::BLACK } else { Color32::WHITE } } else { caret_color_on(fill) }; + let rows = 1; + let snapshot = ed.clone(); + let mut layouter = + |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width); + let output = egui::TextEdit::multiline(&mut ed.text) + .id(egui::Id::new("edit-text-line-input")) + .font(font.clone()) + .margin(egui::Margin::ZERO) + .layouter(&mut layouter) + .text_color(text_color) + .frame(egui::Frame::NONE) + .desired_width(width) + .desired_rows(rows) + .show(ui); + if let Some(cursor) = output.cursor_range { + let selected = cursor.as_sorted_char_range(); + let byte = |index| ed.text.char_indices().nth(index).map_or(ed.text.len(), |(at, _)| at); + ed.selection = (!selected.is_empty()).then(|| byte(selected.start.into())..byte(selected.end.into())); + } + let r = output.response; + if r.changed() { + // The next pass measures the updated draft with the Apply/Save rewrite. + ctx.request_repaint(); + } if ed.focus { r.request_focus(); ed.focus = false; @@ -639,6 +1052,57 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 { mod tests { use super::*; + #[test] + fn text_background_is_cached_without_editing_and_released_on_cancel() { + let mut app = crate::PdfCraftApp::new(); + let bytes = app.session.create_from_text("Preview", "Preview text").unwrap(); + app.open_bytes("preview.pdf", None, bytes.as_ref().clone()).unwrap(); + let doc = app.session.get(app.views[0].id).unwrap(); + let block = doc.text_blocks(0).remove(0); + let source = doc.bytes.clone(); + let generation = doc.edit_generation(); + let ed = LineEditor { + selection: None, + font_resource: None, + page: 0, + block: 0, + text: block.text.clone(), + original: block.text.clone(), + rect: Rect::NOTHING, + source_rect: block.rect.map(|v| v as f32), + multiline: block.rows.len() > 1, + source: None, + fonts: BTreeMap::new(), + overflow: false, + max_width: 400.0, + size: block.size as f32, + focus: true, + look: look_of(&block), + look0: look_of(&block), + extras: Extras::default(), + extras0: Extras::default(), + }; + let ctx = egui::Context::default(); + let mut background = TextBackground::default(); + for _ in 0..100 { + background.prepare(&ctx, doc, Some(&ed), 1.0); + if background.ready(0, 0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(background.ready(0, 0)); + assert!(!background.pending()); + let texture = background.texture.as_ref().unwrap().id(); + background.prepare(&ctx, doc, Some(&ed), 1.0); + assert_eq!(background.texture.as_ref().unwrap().id(), texture); + assert_eq!(doc.bytes, source); + assert_eq!(doc.edit_generation(), generation); + assert!(!doc.dirty && doc.can_undo().is_none()); + background.prepare(&ctx, doc, None, 1.0); + assert!(background.texture.is_none() && background.renderer.is_none()); + } + #[test] fn pale_text_is_edited_on_a_dark_box() { // #913: white text from a coloured banner was typed onto a white box and vanished. diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 87b2f833b..eef99717c 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -12,6 +12,9 @@ use crate::PdfCraftApp; /// Why files were picked. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FilePurpose { + TextFont { + page: usize, + }, Combine, InsertPages, ReplacePages, @@ -23,9 +26,41 @@ pub enum FilePurpose { RedactWords, } +/// Discover installed document fonts off the GUI thread, only when the picker is opened. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn installed_font_files(ctx: &egui::Context) -> Option> { + use std::sync::{Mutex, OnceLock}; + type Files = Arc>>>; + static CACHE: OnceLock = OnceLock::new(); + let files = CACHE.get_or_init(|| { + let files: Files = Arc::new(Mutex::new(None)); + let result = files.clone(); + let ctx = ctx.clone(); + if std::thread::Builder::new() + .name("document-font-discovery".into()) + .spawn(move || { + let fonts = pdfcraft_engine::text_fonts::discover(&pdfcraft_engine::text_fonts::installed_directories()); + if let Ok(mut result) = result.lock() { + *result = Some(fonts); + } + ctx.request_repaint(); + }) + .is_err() + && let Ok(mut result) = files.lock() + { + *result = Some(Vec::new()); + } + files + }); + files.lock().ok().and_then(|f| f.clone()) +} + /// The picker for `purpose`: PDFs, and for Create and Insert also what they convert. fn files_picker(purpose: FilePurpose) -> rfd::AsyncFileDialog { let dialog = rfd::AsyncFileDialog::new(); + if matches!(purpose, FilePurpose::TextFont { .. }) { + return dialog.add_filter("TrueType / OpenType font", &["ttf", "otf"]); + } if purpose == FilePurpose::RedactWords { return dialog.add_filter(tl!("Text"), &["txt"]); } @@ -53,7 +88,7 @@ pub struct ReplaceDraft { impl FilePurpose { /// Insert and Replace edit the active document, at its selection. pub(crate) fn edits_active_document(self) -> bool { - matches!(self, Self::InsertPages | Self::ReplacePages) + matches!(self, Self::InsertPages | Self::ReplacePages | Self::TextFont { .. }) } } @@ -264,7 +299,17 @@ impl PdfCraftApp { self.notify_fmt("{name} is too large for a word list (at most 1 MB).", &[("name", &name)]); return; } - match std::fs::read(p) { + let read = if matches!(purpose, FilePurpose::TextFont { .. }) { + use std::io::Read; + std::fs::File::open(p).and_then(|f| { + let mut bytes = Vec::new(); + f.take((16 << 20) + 1).read_to_end(&mut bytes)?; + Ok(bytes) + }) + } else { + std::fs::read(p) + }; + match read { Ok(b) => files.push((name, b)), Err(e) => { self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]); @@ -368,6 +413,17 @@ impl PdfCraftApp { /// Use picked files (also the entry point for tests and automation). pub fn use_files(&mut self, purpose: FilePurpose, files: Vec<(String, Vec)>) { match purpose { + FilePurpose::TextFont { page } => { + let Some((_, bytes)) = files.into_iter().next() else { return }; + if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes) }) + && let Some((index, id)) = self.active_ids() + && let Some(doc) = self.session.get(id) + && let Some(ed) = self.views[index].line_editor.as_mut() + && let Some(ctx) = self.ctx.as_ref() + { + ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page); + } + } FilePurpose::Combine => self.stage_combine(files), FilePurpose::InsertPages => self.insert_files(files), FilePurpose::ReplacePages => { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index bdc8f5a68..455d55ea3 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -698,6 +698,14 @@ of the following fields: للحقول التالية: Simplified field notation: صيغة الحقول المبسطة: Field names with + - * / and parentheses; put \\ before spaces in names. أسماء الحقول مع + - * / والأقواس؛ ضع \\ قبل المسافات في الأسماء. + +# Document font selection + Finding document fonts… جارٍ البحث عن خطوط المستند… + Installed fonts… الخطوط المثبتة… + Keep source fonts الاحتفاظ بالخطوط الأصلية + Load font… تحميل خط… + No openly licensed document fonts found. Load a font file. لم يُعثر على خطوط ذات ترخيص مفتوح. حمّل ملف خط. + Format text تنسيق النص Bold غامق Italic مائل @@ -1891,6 +1899,7 @@ Delete document JavaScript حذف JavaScript المستند Edit document JavaScript تحرير JavaScript المستند Edit text تحرير النص + Load document font تحميل خط المستند Move image نقل الصورة Rotate image تدوير الصورة Flip image قلب الصورة diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index cf22e43b7..41992bd12 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -378,6 +378,14 @@ of the following fields: на следните полета: Simplified field notation: Опростен запис с полета: Field names with + - * / and parentheses; put \\ before spaces in names. Имена на полета с + - * / и скоби; поставете \\ пред интервалите в имената. + +# Document font selection + Finding document fonts… Търсене на шрифтове за документа… + Installed fonts… Инсталирани шрифтове… + Keep source fonts Запазване на оригиналните шрифтове + Load font… Зареждане на шрифт… + No openly licensed document fonts found. Load a font file. Не са открити шрифтове с отворен лиценз. Заредете файл с шрифт. + Format text Форматирай текст Bold Удебелен Italic Курсив @@ -1936,6 +1944,7 @@ Edit document JavaScript Редактирай JavaScript на документа Edit script of {name} Редактирай скрипта на {name} Edit text Редактирай текста + Load document font Зареждане на шрифт на документа Embedded files must be PDF/A files themselves (use PDF/A-3 for others) Вградените файлове трябва също да са PDF/A файлове (за други използвайте PDF/A-3) Erase Изтриване Fill & Sign: change initials Попълни и подпиши: промяна на инициалите diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4e86518ed..9da79ee9f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -775,6 +775,11 @@ key Space Mezerník of the following fields: následujících polí: Simplified field notation: Zjednodušený zápis polí: Field names with + - * / and parentheses; put \\ before spaces in names. Názvy polí s + - * / a závorkami; mezery v názvech uvozujte znakem \\. + Finding document fonts… Hledání písem dokumentu… + Installed fonts… Nainstalovaná písma… + Keep source fonts Zachovat zdrojová písma + Load font… Načíst písmo… + No openly licensed document fonts found. Load a font file. Nebyla nalezena žádná otevřeně licencovaná písma dokumentu. Načtěte soubor písma. Format text Formát textu Bold Tučné Italic Kurzíva @@ -1941,6 +1946,7 @@ key Space Mezerník Delete document JavaScript Odstranit JavaScript dokumentu Edit document JavaScript Upravit JavaScript dokumentu Edit text Upravit text + Load document font Načíst písmo dokumentu Move image Přesunout obrázek Rotate image Otočit obrázek Flip image Převrátit obrázek diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1caf2527f..925539523 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -671,6 +671,14 @@ of the following fields: der folgenden Felder: Simplified field notation: Vereinfachte Feldschreibweise: Field names with + - * / and parentheses; put \\ before spaces in names. Feldnamen mit + - * / und Klammern; setze \\ vor Leerzeichen in Namen. + +# Document font selection + Finding document fonts… Dokumentschriftarten werden gesucht… + Installed fonts… Installierte Schriftarten… + Keep source fonts Quellschriftarten beibehalten + Load font… Schriftart laden… + No openly licensed document fonts found. Load a font file. Keine offen lizenzierten Schriftarten gefunden. Laden Sie eine Schriftartdatei. + Format text Text formatieren Bold Fett Italic Kursiv @@ -1826,6 +1834,7 @@ Delete document JavaScript Dokument-JavaScript löschen Edit document JavaScript Dokument-JavaScript bearbeiten Edit text Text bearbeiten + Load document font Dokumentschriftart laden Move image Bild verschieben Rotate image Bild drehen Flip image Bild spiegeln diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b40debc8d..36f6db11b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -181,6 +181,7 @@ Details Λεπτομέρειες More Περισσότερα Edit text Επεξεργασία κειμένου + Load document font Φόρτωση γραμματοσειράς εγγράφου Add text Προσθήκη κειμένου Edit image Επεξεργασία εικόνας Add image Προσθήκη εικόνας @@ -1984,6 +1985,11 @@ of the following fields: των παρακάτω πεδίων: Simplified field notation: Απλοποιημένη σύνταξη πεδίων: Field names with + - * / and parentheses; put \\ before spaces in names. Ονόματα πεδίων με + - * / και παρενθέσεις· προσθέστε \\ πριν από τα κενά των ονομάτων. + Finding document fonts… Αναζήτηση γραμματοσειρών εγγράφου… + Installed fonts… Εγκατεστημένες γραμματοσειρές… + Keep source fonts Διατήρηση αρχικών γραμματοσειρών + Load font… Φόρτωση γραμματοσειράς… + No openly licensed document fonts found. Load a font file. Δεν βρέθηκαν γραμματοσειρές εγγράφου με ανοιχτή άδεια. Φορτώστε ένα αρχείο γραμματοσειράς. Format text Μορφοποίηση κειμένου Centre Κεντράρισμα Thai and Arabic use regular fallback fonts when available. Τα ταϊλανδικά και τα αραβικά χρησιμοποιούν κανονικές εναλλακτικές γραμματοσειρές, όταν είναι διαθέσιμες. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c370b31e0..5698b4041 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -694,6 +694,11 @@ of the following fields: de los campos siguientes: Simplified field notation: Notación de campos simplificada: Field names with + - * / and parentheses; put \\ before spaces in names. Nombres de campo con + - * / y paréntesis; pon \\ antes de los espacios en los nombres. + Finding document fonts… Buscando fuentes del documento… + Installed fonts… Fuentes instaladas… + Keep source fonts Conservar las fuentes originales + Load font… Cargar fuente… + No openly licensed document fonts found. Load a font file. No se encontraron fuentes de documento con licencia abierta. Cargue un archivo de fuente. Format text Dar formato al texto Bold Negrita Italic Cursiva @@ -1908,6 +1913,7 @@ action wizard Start Iniciar Delete document JavaScript Eliminar JavaScript del documento Edit document JavaScript Editar JavaScript del documento Edit text Editar texto + Load document font Cargar fuente del documento Move image Mover imagen Rotate image Girar imagen Flip image Voltear imagen diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 484af2b1c..1439c0c7a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -671,6 +671,14 @@ of the following fields: des champs suivants : Simplified field notation: Notation de champs simplifiée : Field names with + - * / and parentheses; put \\ before spaces in names. Noms de champs avec + - * / et parenthèses ; placez \\ devant les espaces dans les noms. + +# Document font selection + Finding document fonts… Recherche de polices pour le document… + Installed fonts… Polices installées… + Keep source fonts Conserver les polices originales + Load font… Charger une police… + No openly licensed document fonts found. Load a font file. Aucune police sous licence libre trouvée. Chargez un fichier de police. + Format text Mettre le texte en forme Bold Gras Italic Italique @@ -1821,6 +1829,7 @@ Delete document JavaScript Supprimer le JavaScript du document Edit document JavaScript Modifier le JavaScript du document Edit text Modifier le texte + Load document font Charger la police du document Move image Déplacer l'image Rotate image Pivoter l'image Flip image Retourner l'image diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2f38f4504..e0d370fa6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -668,6 +668,14 @@ of the following fields: a következő mezőkből: Simplified field notation: Egyszerűsített mezőjelölés: Field names with + - * / and parentheses; put \\ before spaces in names. Mezőnevek + - * / jelekkel és zárójelekkel; a nevekben lévő szóköz elé tegyél \\ jelet. + +# Document font selection + Finding document fonts… Dokumentumbetűtípusok keresése… + Installed fonts… Telepített betűtípusok… + Keep source fonts Eredeti betűtípusok megtartása + Load font… Betűtípus betöltése… + No openly licensed document fonts found. Load a font file. Nem található nyílt licencű betűtípus. Töltsön be egy betűtípusfájlt. + Format text Szöveg formázása Bold Félkövér Italic Dőlt @@ -1811,6 +1819,7 @@ Delete document JavaScript Dokumentum-JavaScript törlése Edit document JavaScript Dokumentum-JavaScript szerkesztése Edit text Szöveg szerkesztése + Load document font Dokumentum betűtípusának betöltése Move image Kép áthelyezése Rotate image Kép forgatása Flip image Kép tükrözése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9868596a1..ee9034452 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -694,6 +694,14 @@ of the following fields: dei seguenti campi: Simplified field notation: Notazione semplificata dei campi: Field names with + - * / and parentheses; put \\ before spaces in names. Nomi dei campi con + - * / e parentesi; anteponi \\ agli spazi nei nomi. + +# Document font selection + Finding document fonts… Ricerca dei caratteri per il documento… + Installed fonts… Caratteri installati… + Keep source fonts Mantieni i caratteri originali + Load font… Carica carattere… + No openly licensed document fonts found. Load a font file. Nessun carattere con licenza aperta trovato. Carica un file di carattere. + Format text Formatta testo Bold Grassetto Italic Corsivo @@ -1896,6 +1904,7 @@ Delete document JavaScript Elimina JavaScript del documento Edit document JavaScript Modifica JavaScript del documento Edit text Modifica testo + Load document font Carica il font del documento Move image Sposta immagine Rotate image Ruota immagine Flip image Capovolgi immagine diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f29e8d125..81b9753e7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -698,6 +698,14 @@ of the following fields: 対象フィールド: Simplified field notation: 簡易フィールド表記: Field names with + - * / and parentheses; put \\ before spaces in names. フィールド名と + - * / および括弧を使用できます。名前に含まれる空白の前には \\ を付けてください。 + +# Document font selection + Finding document fonts… 文書用フォントを検索中… + Installed fonts… インストール済みフォント… + Keep source fonts 元のフォントを保持 + Load font… フォントを読み込む… + No openly licensed document fonts found. Load a font file. オープンライセンスのフォントが見つかりません。フォントファイルを読み込んでください。 + Format text テキストの書式 Bold 太字 Italic 斜体 @@ -1902,6 +1910,7 @@ Delete document JavaScript 文書の JavaScript を削除 Edit document JavaScript 文書の JavaScript を編集 Edit text テキストを編集 + Load document font 文書フォントを読み込む Move image 画像を移動 Rotate image 画像を回転 Flip image 画像を反転 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index b82bf21b9..00e5803c2 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -658,6 +658,11 @@ of the following fields: келесі өрістердің: Simplified field notation: Жеңілдетілген өріс жазылуы: Field names with + - * / and parentheses; put \\ before spaces in names. Құрамында + - * / және жақшалар бар өріс атаулары; атаудағы бос орынның алдында \\ қойыңыз. + Finding document fonts… Құжат қаріптері ізделуде… + Installed fonts… Орнатылған қаріптер… + Keep source fonts Бастапқы қаріптерді сақтау + Load font… Қаріпті жүктеу… + No openly licensed document fonts found. Load a font file. Ашық лицензиялы құжат қаріптері табылмады. Қаріп файлын жүктеңіз. Format text Мәтінді пішімдеу Bold Жуан Italic Көлбеу @@ -1820,6 +1825,7 @@ Delete document JavaScript Құжаттың JavaScript-ін жою Edit document JavaScript Құжаттың JavaScript-ін түзету Edit text Мәтінді түзету + Load document font Құжат қаріпін жүктеу Move image Кескінді жылжыту Rotate image Кескінді бұру Flip image Кескінді аудару diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fd579cf07..c50f89d3d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -693,6 +693,11 @@ of the following fields: następujących pól: Simplified field notation: Uproszczony zapis pól: Field names with + - * / and parentheses; put \\ before spaces in names. Nazwy pól z + - * / i nawiasami; przed spacjami w nazwach wpisz \\. + Finding document fonts… Wyszukiwanie czcionek dokumentu… + Installed fonts… Zainstalowane czcionki… + Keep source fonts Zachowaj czcionki źródłowe + Load font… Wczytaj czcionkę… + No openly licensed document fonts found. Load a font file. Nie znaleziono czcionek dokumentu na otwartej licencji. Wczytaj plik czcionki. Format text Formatuj tekst Bold Pogrubienie Italic Kursywa @@ -1886,6 +1891,7 @@ Delete document JavaScript Usuń JavaScript dokumentu Edit document JavaScript Edytuj JavaScript dokumentu Edit text Edytuj tekst + Load document font Wczytaj czcionkę dokumentu Move image Przenieś obraz Rotate image Obróć obraz Flip image Odbij obraz diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f259d99bc..f9c6be2ce 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -332,6 +332,14 @@ of the following fields: dos seguintes campos: Simplified field notation: Notação simplificada de campo: Field names with + - * / and parentheses; put \\ before spaces in names. Nomes de campos com + - * / e parênteses; coloque \\ antes de espaços em nomes. + +# Document font selection + Finding document fonts… Buscando fontes para o documento… + Installed fonts… Fontes instaladas… + Keep source fonts Manter fontes originais + Load font… Carregar fonte… + No openly licensed document fonts found. Load a font file. Nenhuma fonte com licença aberta encontrada. Carregue um arquivo de fonte. + Format text Formatar texto Bold Negrito Italic Itálico @@ -1808,6 +1816,7 @@ Edit document JavaScript Editar JavaScript do documento Edit script of {name} Editar script de {name} Edit text Editar texto + Load document font Carregar fonte do documento Embedded files must be PDF/A files themselves (use PDF/A-3 for others) Os arquivos incorporados devem ser arquivos PDF/A por si mesmos (use PDF/A-3 para outros) Erase Apagar Fill & Sign: change initials Preencher e assinar: alterar rubrica diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 448723ac2..3fe38181e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -692,6 +692,14 @@ of the following fields: из следующих полей: Simplified field notation: Упрощённая запись полей: Field names with + - * / and parentheses; put \\ before spaces in names. Имена полей с + - * / и скобками; ставьте \\ перед пробелами в именах. + +# Document font selection + Finding document fonts… Поиск шрифтов для документа… + Installed fonts… Установленные шрифты… + Keep source fonts Сохранить исходные шрифты + Load font… Загрузить шрифт… + No openly licensed document fonts found. Load a font file. Шрифты с открытой лицензией не найдены. Загрузите файл шрифта. + Format text Форматировать текст Bold Полужирный Italic Курсив @@ -1892,6 +1900,7 @@ Delete document JavaScript Удалить JavaScript документа Edit document JavaScript Редактировать JavaScript документа Edit text Редактировать текст + Load document font Загрузить шрифт документа Move image Переместить изображение Rotate image Повернуть изображение Flip image Отразить изображение diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index cf8da6636..a29443ab2 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -668,6 +668,7 @@ Edit image Uredi sliko Edit script of {name} Uredi skript: {name} Edit text Uredi besedilo + Load document font Naloži pisavo dokumenta Edit text & images Uredi besedilo in slike Edit text… Uredi besedilo… Editing Urejanje @@ -843,6 +844,11 @@ Form fields panel Razdelek s polji obrazca Form fill-in and digital signatures Izpolnjevanje obrazcev in digitalni podpisi Format Oblika + Finding document fonts… Iskanje pisav dokumenta… + Installed fonts… Nameščene pisave… + Keep source fonts Ohrani izvirne pisave + Load font… Naloži pisavo… + No openly licensed document fonts found. Load a font file. Ni najdenih pisav dokumenta z odprto licenco. Naložite datoteko pisave. Format text Oblikuj besedilo Format: Oblika: Forms Obrazci diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index f6c83b10d..66c654b0d 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -675,6 +675,11 @@ comment menu Edit సవరించు of the following fields: కింది ఫీల్డ్‌లపై: Simplified field notation: సరళీకృత ఫీల్డ్ సూత్రం: Field names with + - * / and parentheses; put \\ before spaces in names. ఫీల్డ్ పేర్లను + - * / గుర్తులు, బ్రాకెట్‌లతో రాయండి; పేర్లలోని ఖాళీల ముందు \\ పెట్టండి. + Finding document fonts… పత్రం ఫాంట్‌లను కనుగొంటోంది… + Installed fonts… ఇన్‌స్టాల్ చేసిన ఫాంట్‌లు… + Keep source fonts మూల ఫాంట్‌లను ఉంచు + Load font… ఫాంట్‌ను లోడ్ చేయి… + No openly licensed document fonts found. Load a font file. బహిరంగ లైసెన్స్ ఉన్న పత్రం ఫాంట్‌లు కనుగొనబడలేదు. ఫాంట్ ఫైల్‌ను లోడ్ చేయండి. Format text టెక్స్ట్ ఫార్మాటింగ్ Bold బోల్డ్ Italic ఇటాలిక్ @@ -1814,6 +1819,7 @@ action wizard Start ప్రారంభించు Delete document JavaScript పత్రం స్థాయి JavaScriptను తొలగించు Edit document JavaScript పత్రం స్థాయి JavaScriptను సవరించు Edit text టెక్స్ట్‌ను సవరించు + Load document font పత్రం ఫాంట్‌ను లోడ్ చేయి Move image చిత్రాన్ని తరలించు Rotate image చిత్రాన్ని తిప్పు Flip image చిత్రాన్ని తిరగేయి diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 07efd7256..8c87c45a3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -656,6 +656,11 @@ of the following fields: aşağıdaki alanların: Simplified field notation: Basitleştirilmiş alan gösterimi: Field names with + - * / and parentheses; put \\ before spaces in names. Adlarda + - * / ve parantez içeren alan adları; adlardaki boşluklardan önce \\ koyun. + Finding document fonts… Belge yazı tipleri aranıyor… + Installed fonts… Yüklü yazı tipleri… + Keep source fonts Kaynak yazı tiplerini koru + Load font… Yazı tipi yükle… + No openly licensed document fonts found. Load a font file. Açık lisanslı belge yazı tipi bulunamadı. Bir yazı tipi dosyası yükleyin. Format text Metni biçimlendir Bold Kalın Italic İtalik @@ -1791,6 +1796,7 @@ Delete document JavaScript Belge JavaScript'ini sil Edit document JavaScript Belge JavaScript'ini düzenle Edit text Metni düzenle + Load document font Belge yazı tipini yükle Move image Resmi taşı Rotate image Resmi döndür Flip image Resmi çevir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 01c6e60c3..d559df0f9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -696,6 +696,14 @@ of the following fields: таких полів: Simplified field notation: Спрощений запис полів: Field names with + - * / and parentheses; put \\ before spaces in names. Назви полів із + - * / та дужками; перед пробілами в назвах ставте \\. + +# Document font selection + Finding document fonts… Пошук шрифтів для документа… + Installed fonts… Встановлені шрифти… + Keep source fonts Зберегти початкові шрифти + Load font… Завантажити шрифт… + No openly licensed document fonts found. Load a font file. Шрифтів із відкритою ліцензією не знайдено. Завантажте файл шрифту. + Format text Форматувати текст Bold Напівжирний Italic Курсив @@ -1889,6 +1897,7 @@ Delete document JavaScript Видалити JavaScript документа Edit document JavaScript Редагувати JavaScript документа Edit text Редагувати текст + Load document font Завантажити шрифт документа Move image Перемістити зображення Rotate image Повернути зображення Flip image Віддзеркалити зображення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 22431ac7c..620659a72 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -384,6 +384,14 @@ of the following fields: 以下字段的: Simplified field notation: 简化字段表示法: Field names with + - * / and parentheses; put \\ before spaces in names. 字段名支持 + - * / 和括号;名称中的空格前加 \\。 + +# Document font selection + Finding document fonts… 正在查找文档字体… + Installed fonts… 已安装字体… + Keep source fonts 保留原始字体 + Load font… 加载字体… + No openly licensed document fonts found. Load a font file. 未找到采用开放许可证的字体。请加载字体文件。 + Format text 设置文本格式 Bold 粗体 Italic 斜体 @@ -1946,6 +1954,7 @@ Edit document JavaScript 编辑文档 JavaScript Edit script of {name} 编辑 {name} 的脚本 Edit text 编辑文本 + Load document font 加载文档字体 Embedded files must be PDF/A files themselves (use PDF/A-3 for others) 嵌入文件本身必须符合 PDF/A 标准(其他类型的文件请使用 PDF/A-3) Erase 擦除 Fill & Sign: change initials 填写与签名:更改姓名首字母 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fa35a5c7c..2cfe8a63d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -340,6 +340,11 @@ of the following fields: 以下欄位的: Simplified field notation: 簡化欄位表示法: Field names with + - * / and parentheses; put \\ before spaces in names. 欄位名支援 + - * / 和括號;名稱中的空格前加 \\。 + Finding document fonts… 正在尋找文件字型… + Installed fonts… 已安裝的字型… + Keep source fonts 保留原始字型 + Load font… 載入字型… + No openly licensed document fonts found. Load a font file. 找不到開放授權的文件字型。請載入字型檔。 Format text 設定文字格式 Bold 粗體 Italic 斜體 @@ -1893,6 +1898,7 @@ Delete document JavaScript 刪除文件 JavaScript Edit document JavaScript 編輯文件 JavaScript Edit text 編輯文字 + Load document font 載入文件字型 Move image 移動圖片 Rotate image 旋轉圖片 Flip image 翻轉圖片 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f3d2cd130..c08a786df 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -348,6 +348,38 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { { 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…")); + } + }); if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) { ed.look = look; changed = true; @@ -360,7 +392,8 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { if let Some(doc) = app.session.get(app.views[i].id) && let Some(block) = doc.text_blocks(ed.page).get(ed.block) { - ed.refresh_source(block); + ed.refresh_source(block, doc.text_lines(ed.page)); + ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); } } app.views[i].line_editor = Some(ed); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 40e85436e..a690ca72e 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1890,13 +1890,14 @@ fn copying_out_of_a_document_honours_its_security() { app.submit_password(Some(password.into())); app }); - // Select the page's text once its text layer is loaded. - for _ in 0..40 { + // Select the page's text once its text layer is loaded (up to ~4 s: a loaded test machine + // can take far longer than the 0.2 s this used to allow). + for _ in 0..200 { h.run_steps(2); if h.state_mut().views[0].select_all() { break; } - std::thread::sleep(std::time::Duration::from_millis(5)); + std::thread::sleep(std::time::Duration::from_millis(20)); } assert_eq!(h.state().views[0].selected_text().as_deref(), Some("Page 1")); h @@ -2050,7 +2051,10 @@ fn copying_cutting_and_pasting_pages() { } fn source_font_fixture() -> Vec { - let body = "BT /F1 18 Tf 20 220 Td (Serif) Tj ET BT /F2 18 Tf 20 120 Td (Mono) Tj ET"; + source_font_fixture_with_text("BT /F1 18 Tf 20 220 Td (Serif) Tj ET BT /F2 18 Tf 20 120 Td (Mono) Tj ET") +} + +fn source_font_fixture_with_text(body: &str) -> Vec { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 300] >>".into(), @@ -2118,6 +2122,109 @@ fn clicking_existing_text_selects_its_source_font_style() { assert!(!ed.look.bold && ed.look.italic, "source style: {:?}", ed.look); } +#[test] +fn deleting_a_draft_does_not_show_the_original_text_underneath() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + 142.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); + h.state_mut().views[0].line_editor.as_mut().unwrap().text.clear(); + for _ in 0..100 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let shot = h.render().unwrap(); + if let Ok(path) = std::env::var("PDFCRAFT_TEXT_PREVIEW_SHOT") { + shot.save(path).unwrap(); + } + // The right half of the original glyphs, away from the caret and editor border. + let k = r.width() / 200.0; + for y in 152..166 { + for x in 65..82 { + let px = (r.left() + x as f32 * k) as u32; + let py = (r.top() + (300 - y) as f32 * k) as u32; + let [red, green, blue, alpha] = shot.get_pixel(px, py).0; + assert!( + !(red < 100 && green < 100 && blue < 100) && alpha == 255, + "the deleted source is still visible at {x},{y}: {:?}", + [red, green, blue, alpha] + ); + } + } + assert!(!dirty(&h), "the document is unchanged until the draft is applied"); + assert_eq!(page_texts(h.state()), ["Page 1"]); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 1"]); +} + +#[test] +fn switching_from_a_narrow_editor_keeps_one_frame_and_a_wide_heading_on_one_line() { + let bytes = source_font_fixture_with_text( + "BT /F1 18 Tf 20 220 Td (Editable PDF heading) Tj ET BT /F2 18 Tf 20 120 Td (Mono) Tj ET BT /F2 18 Tf 20 98 Td (Text) Tj ET", + ); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("editor.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + for y in [128.0, 228.0] { + let point = egui::pos2(page.left() + 25.0 / 300.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height()); + h.hover_at(point); + h.run_steps(1); + h.drag_at(point); + h.run_steps(1); + h.drop_at(point); + h.run_steps(4); + if y == 128.0 { + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "Mono\nText", "opening a paragraph keeps its source line breaks"); + h.key_press(Key::Escape); + h.run_steps(2); + } + } + let heading = h + .output() + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text() == "Editable PDF heading" => Some(&text.galley), + _ => None, + }) + .expect("the heading editor is painted"); + assert_eq!(heading.rows.len(), 1, "opening a wider block must not reuse the previous editor's narrow width"); + let frames = h + .output() + .shapes + .iter() + .filter(|shape| { + matches!(&shape.shape, + egui::Shape::Rect(rect) if rect.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6) + && rect.rect.width() > 100.0 && page.contains(rect.rect.center()) + ) + }) + .count(); + assert_eq!(frames, 1, "the active block has only the editor frame"); + if let Ok(path) = std::env::var("PDFCRAFT_TEXT_FRAME_SHOT") { + h.render().unwrap().save(path).unwrap(); + } + assert!(!dirty(&h)); +} + #[test] fn editing_existing_text_in_place() { let mut h = harness(1, |_| {}); @@ -2134,8 +2241,8 @@ fn editing_existing_text_in_place() { h.run_steps(3); let ed = h.state().views[0].line_editor.clone().expect("the line opens for editing"); assert_eq!((ed.page, ed.block, ed.text.as_str()), (0, 0, "Page 1")); - // A single-line paragraph's box may grow to the page's edge as the text does. - assert!(ed.growth().is_some(), "the editor box may grow"); + // The source container stays visible while added text can overflow it. + assert!(ed.growth().is_none(), "opening the editor preserves the source container"); h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into(); h.run_steps(1); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter); @@ -2311,6 +2418,37 @@ fn the_format_panel_restyles_the_paragraph_being_edited() { assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline)); } +#[test] +fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + 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(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::ArrowRight); + h.run_steps(1); + 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.run_steps(5); + let id = h.state().views[0].id; + let doc = h.state().session.get(id).unwrap(); + let lines = doc.text_lines(0); + assert!(lines.iter().any(|l| l.text == "1" && l.base_font == "Helvetica-Bold"), "{lines:?}"); + assert!(lines.iter().any(|l| l.text.trim_end() == "Page" && l.base_font == "Helvetica")); + let saved = h.state_mut().session.save_bytes(id).unwrap(); + let mut reopen = pdfcraft_engine::Session::new(); + let rid = reopen.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(reopen.get(rid).unwrap().text_lines(0), lines); +} + /// Drag with the pointer from `from` to `to` in a few steps. fn drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) { h.hover_at(from); @@ -3016,3 +3154,740 @@ fn host_dirty_hears_when_unsaved_work_appears_and_goes() { assert_eq!(reports.lock().unwrap().last(), Some(&false), "saving clears it"); let _ = std::fs::remove_file(out); } + +#[test] +fn one_editor_contains_mixed_styles_and_visual_rows() { + let bytes = source_font_fixture_with_text( + "BT 0 0.2 0.5 rg /F1 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET \ + BT 0 g /F2 12 Tf 20 215 Td (Plain ) Tj /F1 12 Tf 0 0.2 0.5 rg (bold) Tj /F2 12 Tf 0 g ( words) Tj ET \ + BT /F2 10 Tf 20 195 Td (Last row) Tj ET", + ); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("mixed.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let point = egui::pos2(page.left() + 30.0 / 300.0 * page.width(), page.top() + 55.0 / 300.0 * page.height()); + h.hover_at(point); + h.run_steps(1); + h.drag_at(point); + h.run_steps(1); + h.drop_at(point); + h.run_steps(6); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "Heading\nPlain bold words\nLast row"); + let galley = h + .output() + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(t) if t.galley.text().starts_with("Heading\n") => Some(&t.galley), + _ => None, + }) + .expect("one galley contains the whole region"); + assert_eq!(galley.rows.len(), 3); + assert!(galley.job.sections.iter().any(|s| s.format.color == egui::Color32::BLACK)); + assert!(galley.job.sections.iter().any(|s| s.format.color == egui::Color32::from_rgb(0, 51, 128))); + assert!(galley.job.sections.iter().any(|s| s.format.font_id.size > galley.job.sections.last().unwrap().format.font_id.size)); + let original_text = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.state_mut().views[0].line_editor.as_mut().unwrap().text.push_str("\nAdditional row\nAnother row"); + h.run_steps(3); + let visible = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text().ends_with("Another row") => Some(t.galley.rect.translate(t.pos.to_vec2())), + _ => None, + }) + .unwrap(); + let frame = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6) && r.rect.width() > 50.0 => Some(r.rect), + _ => None, + }) + .unwrap(); + assert!(frame.contains_rect(visible), "fallback font rows remain inside the growing frame"); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = original_text; + h.run_steps(3); + if let Ok(path) = std::env::var("PDFCRAFT_RICH_FRAME_SHOT") { + h.render().unwrap().save(path).unwrap(); + } + h.state_mut().views[0].line_editor.as_mut().unwrap().text.clear(); + h.run_steps(3); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run_steps(4); + assert!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0).is_empty()); + assert!(dirty(&h)); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rows.len(), 3); +} + +/// Opt-in private-document regression. This never captures images or saves the source file. +/// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored. +#[test] +#[ignore = "requires an explicitly supplied local PDF"] +fn private_document_regions_open_as_one_editor() { + let path = std::env::var("PDFCRAFT_TEXT_REGRESSION_PDF").expect("supply a local PDF path"); + let bytes = std::fs::read(&path).unwrap(); + let original = bytes.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("private.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + assert!(h.state_mut().execute("view.layout.single")); + h.run_steps(3); + let page_count = h.state().session.get(h.state().views[0].id).unwrap().info.pages.len(); + let mut checked = 0; + for page_index in 0..page_count { + h.state_mut().views[0].go_to_page(page_index); + h.run_steps(4); + let blocks = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(page_index); + let page = h.state().views[0].page_screen_rect(page_index).unwrap(); + let info = h.state().session.get(h.state().views[0].id).unwrap().info.pages[page_index].clone(); + let source_lines = h.state().session.get(h.state().views[0].id).unwrap().text_lines(page_index); + let programs = h.state().session.get(h.state().views[0].id).unwrap().text_editor_fonts(page_index); + assert!(!programs.is_empty(), "source sfnt fonts must be usable in the editor"); + let scale = page.width() / info.width; + for (index, block) in blocks.iter().enumerate().filter(|(_, b)| b.rows.len() >= 3) { + let line = &source_lines[block.lines[0]]; + let v = info.user_to_view((line.rect[0] + 3.0) as f32, (line.rect[1] + line.rect[3]) as f32 / 2.0); + let point = egui::pos2(page.left() + v[0] * scale, page.top() + v[1] * scale); + h.hover_at(point); + h.run_steps(1); + h.drag_at(point); + h.run_steps(1); + h.drop_at(point); + h.run_steps(8); + let editor = h.state().views[0].line_editor.as_ref().unwrap(); + assert_eq!(editor.block, index); + assert_eq!(editor.text, block.editor_text); + assert!(!editor.overflow, "unchanged source region must fit (page {}, region {})", page_index + 1, index + 1); + let text_shape = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == block.editor_text => Some(t), + _ => None, + }) + .expect("the whole source region is editable"); + for (section, (_, source)) in text_shape.galley.job.sections.iter().zip(block.edited_spans(&block.editor_text)) { + if programs.iter().any(|font| font.resource == source_lines[source].font) { + assert!( + matches!(§ion.format.font_id.family, + egui::FontFamily::Name(name) if name.starts_with("pdf-source-")), + "each available embedded source face is used in the editor" + ); + } + } + assert_eq!(text_shape.galley.rows.len(), block.rows.len(), "source rows stay in place"); + for (row, source_row) in text_shape.galley.rows.iter().zip(&block.rows) { + let source = &source_lines[source_row[0]]; + let baseline = text_shape.pos.y + row.pos.y + row.glyphs[0].pos.y; + let expected = page.top() + info.user_to_view(0.0, source.baseline()[1] as f32)[1] * scale; + assert!((baseline - expected).abs() < 1.5, "editor baseline stays on the source baseline"); + } + let frames = h + .output() + .shapes + .iter() + .filter(|s| { + matches!(&s.shape, + egui::Shape::Rect(r) if r.stroke.color==egui::Color32::from_rgb(0x14,0x73,0xE6) + && r.rect.width()>50.0 && page.contains(r.rect.center())) + }) + .count(); + assert_eq!(frames, 1, "one stable editor frame"); + h.state_mut().views[0].line_editor.as_mut().unwrap().text.push_str("\nAdditional text\nAdditional text"); + h.run_steps(3); + let editor = h.state().views[0].line_editor.as_ref().unwrap(); + assert!(editor.overflow, "extra rows extend the block"); + let draft = + h.state().session.get(h.state().views[0].id).unwrap().text_edit_layout(page_index, index, &editor.text, &editor.style()).unwrap(); + let bounds = draft.draft_rect.unwrap(); + assert!((bounds[3] - block.rect[3]).abs() < 0.5, "draft bounds exclude untouched neighboring regions"); + h.key_press(Key::Escape); + h.run_steps(2); + checked += 1; + } + } + assert!(checked > 0, "the supplied PDF must contain a multiline region"); + assert!(!dirty(&h)); + assert_eq!(std::fs::read(path).unwrap(), original); +} + +#[test] +fn inline_editor_preserves_source_font_and_previews_live_bounds() { + use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; + use skrifa::MetadataProvider as _; + let mut cos = Document::open(std::sync::Arc::new(source_font_fixture_with_text( + "BT /F1 18 Tf 20 220 Td (Stable source text) Tj ET BT /F1 12 Tf 20 194 Td (The second row stays here) Tj ET", + ))) + .unwrap(); + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let metrics = face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let glyphs = face.glyph_metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let units = 1000.0 / f64::from(metrics.units_per_em); + let program = cos.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + descriptor.set(b"Ascent".to_vec(), Object::Real(f64::from(metrics.ascent) * units)); + descriptor.set(b"Descent".to_vec(), Object::Real(f64::from(metrics.descent) * units)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"BaseFont".to_vec(), Object::name("Inter-Regular")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + font.set(b"FirstChar".to_vec(), Object::Int(32)); + font.set(b"LastChar".to_vec(), Object::Int(126)); + font.set( + b"Widths".to_vec(), + Object::Array( + (32..=126u8) + .map(|c| { + let id = face.charmap().map(char::from(c)).unwrap(); + Object::Real(f64::from(glyphs.advance_width(id).unwrap()) * units) + }) + .collect(), + ), + ); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + cos.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| *d = font).unwrap(); + let bytes = write_incremental(&cos, &Default::default()).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("stable.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let source = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].clone(); + let lines = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let scale = page.width() / 300.0; + let point = egui::pos2(page.left() + 30.0 * scale, page.top() + 75.0 * scale); + h.hover_at(point); + h.run_steps(1); + h.drag_at(point); + h.run_steps(1); + h.drop_at(point); + h.run_steps(8); + assert!(!h.state().views[0].line_editor.as_ref().unwrap().overflow); + let shape = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == source.editor_text => Some(t), + _ => None, + }) + .unwrap(); + assert_eq!(shape.galley.rows.len(), 2); + for (row, source_row) in shape.galley.rows.iter().zip(&source.rows) { + let line = &lines[source_row[0]]; + let y = shape.pos.y + row.pos.y + row.glyphs[0].pos.y; + assert!((y - (page.top() + (300.0 - line.baseline()[1] as f32) * scale)).abs() < 1.0); + } + for section in &shape.galley.job.sections { + assert!(matches!(§ion.format.font_id.family,egui::FontFamily::Name(name) if name.starts_with("pdf-source-"))); + } + let frame = |h: &Harness<'_, PdfCraftApp>| { + h.output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6) && r.rect.width() > 100.0 => Some(r.rect), + _ => None, + }) + .unwrap() + }; + let original_frame = frame(&h); + h.state_mut().views[0].line_editor.as_mut().unwrap().text.push_str("\nExtra row that does not fit\nAnother row"); + h.run_steps(3); + assert!(h.state().views[0].line_editor.as_ref().unwrap().overflow); + let expanded = frame(&h); + assert!(expanded.bottom() > original_frame.bottom() + 20.0, "the frame visibly expands around the extra rows"); + assert_eq!(expanded.left(), original_frame.left()); + assert_eq!(expanded.top(), original_frame.top()); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Stable source text".into(); + h.run_steps(3); + let reduced = frame(&h); + assert!(reduced.bottom() < original_frame.bottom() - 20.0, "deleting a row visibly shrinks the block"); + h.state_mut().views[0].line_editor.as_mut().unwrap().text.push_str("\nExtra row that does not fit\nAnother row"); + h.run_steps(3); + if let Ok(path) = std::env::var("PDFCRAFT_OVERFLOW_FRAME_SHOT") { + h.render().unwrap().save(path).unwrap(); + } + let id = h.state().views[0].id; + let editor = h.state().views[0].line_editor.as_ref().unwrap(); + let draft_text = editor.text.clone(); + let measured = h.state().session.get(id).unwrap().text_edit_layout(0, 0, &draft_text, &editor.style()).unwrap().draft_rect.unwrap(); + let expected_frame = egui::Rect::from_min_max( + egui::pos2(page.left() + measured[0] as f32 * scale, page.top() + (300.0 - measured[3] as f32) * scale), + egui::pos2(page.left() + measured[2] as f32 * scale, page.top() + (300.0 - measured[1] as f32) * scale), + ) + .expand(2.0); + let live = frame(&h); + assert!( + (live.min - expected_frame.min).length() < 0.1 && (live.max - expected_frame.max).length() < 0.1, + "the live frame shows the same PDF geometry as Apply/Save" + ); + assert!(!dirty(&h), "draft measurement does not modify the document"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run_steps(3); + assert!(dirty(&h)); + assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, measured); + let bytes = h.state().session.save_bytes(id).unwrap(); + let mut reopened = pdfcraft_engine::Session::new(); + let reopened_id = reopened.open("again.pdf", None, bytes, None).unwrap(); + assert_eq!(reopened.get(reopened_id).unwrap().text_blocks(0)[0].rect, measured); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0], source); +} + +fn clipped_artwork_with_labels() -> Vec { + clipped_artwork_with_labels_drawn(false) +} + +/// `own_clip` draws the image alone in its clip (`q re W n cm Do Q`), which moves with it (#1049). +fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec { + use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; + let mut doc = Document::open(std::sync::Arc::new(fixture(2))).unwrap(); + let mut image = Dict::new(); + image.set(b"Type".to_vec(), Object::name("XObject")); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(1)); + image.set(b"Height".to_vec(), Object::Int(1)); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let image = doc.add(Object::Stream(Stream::flate(image, &[220, 235, 245]))); + for page in pdfcraft_model::pages(&doc) { + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), + if own_clip { + &b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] + } else { + &b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] + }))); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(image)); + resources.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| { + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + }) + .unwrap(); + } + write_incremental(&doc, &Default::default()).unwrap() +} + +#[test] +fn cropped_images_do_not_intercept_text_and_stay_inside_each_page() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + assert!(h.state_mut().execute("view.layout.single")); + h.run_steps(3); + for page in 0..2 { + h.state_mut().views[0].go_to_page(page); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(page).unwrap(); + let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height()); + for (x, y, label) in [(65.0, 134.0, "Editable caption"), (65.0, 224.0, "Overlay")] { + let p = at(x, y); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, label); + assert!(h.state().views[0].image_selection.is_none(), "text wins even inside the visible artwork"); + h.key_press(Key::Escape); + h.run_steps(2); + } + let p = at(95.0, 195.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.page == page)); + for shape in &h.output().shapes { + if let egui::Shape::Rect(rect) = &shape.shape + && rect.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6) + && rect.rect.width() > 50.0 + { + assert!(r.expand(3.0).contains_rect(rect.rect), "no image frame escapes its own page"); + } + } + } + assert!(!dirty(&h)); +} + +#[test] +fn cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels_drawn(true)).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + assert!(h.state_mut().execute("view.layout.single")); + h.run_steps(3); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height()); + let p = at(95.0, 195.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + let id = h.state().views[0].id; + assert!(h.state().views[0].image_selection.is_some()); + let text = |doc: &pdfcraft_engine::Document| { + doc.text_lines(0).into_iter().map(|l| (l.text.clone(), l.rect, l.font.clone(), l.size, l.color, l.baseline())).collect::>() + }; + let original_text = text(h.state().session.get(id).unwrap()); + // Grow then shrink using the visible top-right grip, never the off-page raw placement. + for (factor, right, top, horizontal_only, off_centre) in [ + (1.15, 179.5, 262.0, false, false), + (0.7, 134.65, 234.4, false, false), + (0.75, 108.4875, 218.3, true, false), + (1.1, 116.33625, 223.13, false, true), + ] { + let before = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + let grip = at(before[2] as f32, before[3] as f32); + let offset = if off_centre { egui::vec2(3.0, -3.0) } else { egui::Vec2::ZERO }; + let from = grip + offset; + let fixed = at(before[0] as f32, before[1] as f32); + let desired = fixed + (grip - fixed) * factor + offset; + let to = if horizontal_only { egui::pos2(desired.x, from.y) } else { desired }; + let generation = h.state().session.get(id).unwrap().edit_generation(); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(from + (to - from) * (k as f32 / 5.0)); + h.run_steps(1); + } + assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "dragging previews without committing every frame"); + h.drop_at(to); + h.run_steps(4); + assert!(h.state().views[0].line_editor.is_none(), "a label under a selected image grip cannot take its drag"); + let after = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + for (actual, expected) in after.into_iter().zip([30.0, 170.0, right, top]) { + assert!((actual - expected).abs() < 0.05, "cropped image follows the visible grip: {actual} vs {expected}"); + } + assert_eq!(text(h.state().session.get(id).unwrap()), original_text, "image resizing leaves captions in place"); + } + let saved = h.state().session.save_bytes(id).unwrap(); + let mut reopened = pdfcraft_engine::Session::new(); + let re = reopened.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(reopened.get(re).unwrap().page_images(0), h.state().session.get(id).unwrap().page_images(0)); +} + +/// Exercise actual pointer gestures on each cropped placement, without capturing private pixels. +#[test] +#[ignore = "requires an explicitly supplied local PDF with cropped artwork"] +fn private_cropped_image_grips_resize_without_moving_the_opposite_corner() { + let path = std::env::var("PDFCRAFT_CLIPPED_ARTWORK_PDF").expect("supply a local PDF path"); + let original = std::fs::read(&path).unwrap(); + let bytes = original.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("private.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + assert!(h.state_mut().execute("view.layout.single")); + let id = h.state().views[0].id; + let pages = h.state().session.get(id).unwrap().info.pages.len(); + let mut checked = 0; + for page_index in 0..pages { + h.state_mut().views[0].go_to_page(page_index); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + let info = doc.info.pages[page_index].clone(); + let images = doc.page_images(page_index); + let blocks = doc.text_blocks(page_index); + let page = h.state().views[0].page_screen_rect(page_index).unwrap(); + let at = |x: f64, y: f64| { + let v = info.user_to_view(x as f32, y as f32); + page.min + egui::vec2(v[0], v[1]) * (page.width() / info.width) + }; + for (index, image) in images.iter().enumerate() { + let Some(b) = image.visible_rect else { continue }; + let blank = (1..10) + .flat_map(|x| (1..10).map(move |y| (x, y))) + .map(|(x, y)| (b[0] + (b[2] - b[0]) * x as f64 / 10.0, b[1] + (b[3] - b[1]) * y as f64 / 10.0)) + .find(|(x, y)| !blocks.iter().any(|l| *x >= l.rect[0] && *x <= l.rect[2] && *y >= l.rect[1] && *y <= l.rect[3])) + .expect("image has an unlabelled selection point"); + let corners = [at(b[0], b[3]), at(b[2], b[3]), at(b[0], b[1]), at(b[2], b[1])]; + for corner in 0..4 { + for (factor, axis) in [(0.8, 0), (1.04, 0), (0.8, 1), (0.8, 2)] { + let p = at(blank.0, blank.1); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(index)); + let generation = h.state().session.get(id).unwrap().edit_generation(); + let from = corners[corner]; + let fixed = corners[3 - corner]; + let desired = fixed + (from - fixed) * factor; + let to = match axis { + 1 => egui::pos2(desired.x, from.y), + 2 => egui::pos2(from.x, desired.y), + _ => desired, + }; + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(from + (to - from) * (k as f32 / 5.0)); + h.run_steps(1); + assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "no PDF rewrite on pointer movement"); + } + h.drop_at(to); + h.run_steps(4); + let after = h.state().session.get(id).unwrap().page_images(page_index)[index].visible_rect.unwrap(); + let actual = egui::Rect::from_two_pos(at(after[0], after[1]), at(after[2], after[3])); + let expected = egui::Rect::from_two_pos(fixed, desired).intersect(page); + assert!( + (actual.min - expected.min).length() < 0.2 && (actual.max - expected.max).length() < 0.2, + "page {}, image {}, corner {corner}: {actual:?} vs {expected:?}", + page_index + 1, + index + 1 + ); + assert!(h.state().views[0].line_editor.is_none()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + checked += 1; + } + } + } + } + assert_eq!(h.state().session.get(id).unwrap().bytes.as_slice(), original.as_slice(), "undo restored the source document in memory"); + assert_eq!(std::fs::read(path).unwrap(), original); + println!("verified {checked} real-file corner drag gestures on {pages} pages"); +} + +/// Local acceptance check for PDFs containing cropped artwork. No screenshots or source saves. +#[test] +#[ignore = "requires an explicitly supplied local PDF with cropped artwork"] +fn private_document_cropped_artwork_does_not_block_text() { + let path = std::env::var("PDFCRAFT_CLIPPED_ARTWORK_PDF").expect("supply a local PDF path"); + let original = std::fs::read(&path).unwrap(); + let bytes = original.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("private.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + assert!(h.state_mut().execute("view.layout.single")); + h.run_steps(3); + let pages = h.state().session.get(h.state().views[0].id).unwrap().info.pages.len(); + let mut checked = 0; + let mut cropped = 0; + for page in 0..pages { + h.state_mut().views[0].go_to_page(page); + h.run_steps(4); + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + let info = doc.info.pages[page].clone(); + let blocks = doc.text_blocks(page); + let images = doc.page_images(page); + let r = h.state().views[0].page_screen_rect(page).unwrap(); + for image in &images { + let visible = image.visible_rect.unwrap(); + assert!(visible[0] >= f64::from(info.crop[0]) - 0.01 && visible[1] >= f64::from(info.crop[1]) - 0.01); + assert!(visible[2] <= f64::from(info.crop[2]) + 0.01 && visible[3] <= f64::from(info.crop[3]) + 0.01); + if image.rect != visible { + cropped += 1; + } + } + for (index, block) in blocks.iter().enumerate() { + let [x0, y0, x1, y1] = block.rect; + let v = info.user_to_view(((x0 + x1) / 2.0) as f32, ((y0 + y1) / 2.0) as f32); + let scale = r.width() / info.width; + let p = egui::pos2(r.left() + v[0] * scale, r.top() + v[1] * scale); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.block), Some(index), "every source block can open directly"); + assert!(h.state().views[0].image_selection.is_none()); + h.key_press(Key::Escape); + h.run_steps(2); + checked += 1; + } + } + assert!(checked > 0 && cropped > 0); + assert!(!dirty(&h)); + assert_eq!(std::fs::read(path).unwrap(), original); + println!("checked {pages} pages, {checked} text regions, {cropped} clipped image placements"); +} + +/// Actual keyboard input into every page of a private Chinese PDF; no private captures. +#[test] +#[ignore = "requires a private Chinese PDF and an approved full source font file"] +fn private_new_chinese_keyboard_input_preserves_font_layout_and_saved_pixels() { + use pdfcraft_engine::{Edit, Session}; + use std::sync::Arc; + let path = std::env::var("PDFCRAFT_CHINESE_TEXT_PDF").expect("supply a local PDF path"); + let font_path = std::env::var("PDFCRAFT_CHINESE_FULL_FONT").expect("supply the full source face"); + let original = std::fs::read(&path).unwrap(); + let bytes = original.clone(); + let font = Arc::new(std::fs::read(font_path).unwrap()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("private.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + assert!(h.state_mut().execute("view.layout.single")); + let id = h.state().views[0].id; + let pages = h.state().session.get(id).unwrap().info.pages.len(); + let all: String = (0..pages).flat_map(|p| h.state().session.get(id).unwrap().text_lines(p)).map(|l| l.text).collect(); + let new: String = "测试编辑删除龘".chars().filter(|c| !all.contains(*c)).take(4).collect(); + assert!(new.chars().count() >= 2); + let pixels = |bytes: Arc>, page| { + let mut r = PageRenderer::new(bytes, Default::default()); + let p = r.render(RenderRequest { page, scale: 1.5, ..Default::default() }); + assert!(p.error.is_none() && p.warnings.is_empty()); + p.rgba.to_vec() + }; + for page_index in 0..pages { + h.state_mut().views[0].go_to_page(page_index); + h.state_mut().session.apply(id, Edit::RegisterTextFont { page: page_index, bytes: font.clone() }).unwrap(); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + let (index, block) = doc + .text_blocks(page_index) + .into_iter() + .enumerate() + .find(|(_, b)| b.rows.len() == 1 && b.editor_text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c))) + .unwrap(); + let source = doc.text_lines(page_index)[block.lines[0]].clone(); + let info = doc.info.pages[page_index].clone(); + let page = h.state().views[0].page_screen_rect(page_index).unwrap(); + let scale = page.width() / info.width; + let v = info.user_to_view((source.rect[0] + 3.0) as f32, ((source.rect[1] + source.rect[3]) / 2.0) as f32); + let point = page.min + egui::vec2(v[0], v[1]) * scale; + h.hover_at(point); + h.run_steps(1); + h.drag_at(point); + h.run_steps(1); + h.drop_at(point); + h.run_steps(8); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().block, index); + let draft = format!("{}{new}", block.editor_text); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(egui::Event::Text(draft.clone())); + h.run_steps(4); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, draft); + let galley = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == draft => Some(t.galley.clone()), + _ => None, + }) + .expect("typed Chinese is in the actual inline editor"); + assert!( + galley + .job + .sections + .iter() + .filter(|s| draft + .get(usize::from(s.byte_range.start)..usize::from(s.byte_range.end)) + .is_some_and(|text| text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)))) + .all(|s| matches!(&s.format.font_id.family, egui::FontFamily::Name(n) if n.starts_with("pdf-source-"))) + ); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run_steps(5); + assert!(h.state().views[0].line_editor.is_none()); + let edited = h.state().session.get(id).unwrap(); + assert!(edited.text_lines(page_index).iter().any(|l| l.text.contains(&new) && l.base_font == "DroidSansFallback")); + let first = edited.text_lines(page_index).into_iter().find(|l| l.text.starts_with(source.text.trim())).unwrap(); + assert!(first.baseline().iter().zip(source.baseline()).all(|(a, b)| (a - b).abs() < 0.01)); + let current = pixels(edited.display.clone(), page_index); + let saved = h.state_mut().session.save_bytes(id).unwrap(); + let mut reopen = Session::new(); + let rid = reopen.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(pixels(reopen.get(rid).unwrap().display.clone(), page_index), current); + // Repeat keyboard input and deletion without recreating the editor via state mutation. + h.hover_at(point); + h.run_steps(1); + h.drag_at(point); + h.run_steps(1); + h.drop_at(point); + h.run_steps(6); + assert!(h.state().views[0].line_editor.as_ref().unwrap().text.contains(&new)); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + let shortened = draft.replace(&new, &new.chars().skip(1).collect::()); + h.event(egui::Event::Text(shortened.clone())); + h.run_steps(3); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run_steps(4); + let current = pixels(h.state().session.get(id).unwrap().display.clone(), page_index); + let saved = h.state_mut().session.save_bytes(id).unwrap(); + let rid = reopen.open("deleted.pdf", None, saved, None).unwrap(); + assert_eq!(pixels(reopen.get(rid).unwrap().display.clone(), page_index), current); + let content = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::(); + assert_eq!(content(&reopen.get(rid).unwrap().text_blocks(page_index)[index].editor_text), content(&shortened)); + h.state_mut().session.undo(id).unwrap(); + h.state_mut().session.undo(id).unwrap(); + h.state_mut().session.undo(id).unwrap(); + h.run_steps(4); + } + assert_eq!(std::fs::read(path).unwrap(), original); + println!("verified Chinese keyboard insertion/deletion and save/reopen rendering on {pages} pages"); +} diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md new file mode 100644 index 000000000..525f99390 --- /dev/null +++ b/docs/editing-existing-text.md @@ -0,0 +1,51 @@ +# Editing existing text and source subsets + +Click a text region in **Edit a PDF → Edit text and images**. One editor contains its +source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory; +source font programs, colour, size and baseline anchors stay intact. A textless preview +prevents a second copy underneath. The frame shows the live extent as text grows or shrinks. + +A subset font contains only some of the original face's glyphs. A new character cannot be +reconstructed from a glyph the PDF does not contain. **Load font…** imports an openly licensed, +embeddable standalone `.ttf` or `.otf` face. **Installed fonts…** discovers eligible user-installed +faces on request, off the UI thread; the document-font menu selects resources already on the +page. Select **Keep source fonts** to preserve each run's original face. A matching full face +completes its subsets automatically, including new Chinese characters. Choosing another face +is explicit. Fonts with restricted embedding, unsupported collections, excluded families or +no open-licence declaration are refused. Interface fallback fonts are never exported. + +The full selected program, glyph mappings, widths and Unicode text are embedded in the PDF. +Saving and reopening therefore needs no locally installed font. Original subset bytes are +preserved. Optional matching faces from `CRAFT_FONTS_DIR` can also complete source subsets; +this works only when that build input actually contains the full face. This change does not +add a font binary or change the release font pin. Fonts shared by releases belong in +`storytold/craft-fonts` under the repository's asset rules. + +Select text within the editor before changing size, colour, font, underline or spacing to +format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the +same properties; invalid or overlapping ranges fail atomically. Untouched source formatting +survives. OpenType shaping uses the selected/source face's substitution and positioning +rules, preserves marks and ligatures, and applies Unicode bidi ordering for a single face. +Logical text remains available after save/reopen through ToUnicode and ActualText. + +For headless editing, use `text_font_files` → `text_font_import` → `text_fonts` and pass a +resource to `text_edit.font_resource`. `text_edit.ranges` holds selected range formatting. +`text_edit_layout` accepts resource/ranges too and measures a draft without applying it. +All file inputs respect CLI root confinement; importing a font and applying text are undoable. + +Explicit marked paragraph/heading scopes prevent unrelated tagged regions from merging. +Compatible geometry groups untagged content, including runs split across content streams +or equivalent graphics transforms. Geometry cannot infer author intent in every untagged +PDF. Semantic structure-tree preservation, legacy Type 1/Type 3 inline font preview, +font collections/variable-font instances, mixed-font/style contextual runs and complete rotated +layout fidelity still need broader coverage. Do not interpret the passing real-file checks +as a guarantee for every PDF. + +Private acceptance tests are opt-in and keep inputs unmodified. They exercise actual Chinese +keyboard insertion/deletion on all four pages, source-face/baseline checks and identical +in-memory renders before and after saving/reopening. Synthetic tests cover full TrueType +mapping, OpenType/CFF, Arabic marks/ligatures/mixed bidi, repeated edits, disjoint styles, +invalid ranges, font selection and root confinement. CLI acceptance repeats insertion/deletion +and two saves/reopens on four pages; independent PDFium checks extraction and rendering. +No private document, font or screenshot +is included in the repository. diff --git a/docs/editing-images.md b/docs/editing-images.md index fde18518f..d4bc4dd99 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -23,3 +23,20 @@ Forms have `pixels: [0, 0]`, since they have no single bitmap resolution. Use th number with `image_edit` (move, rotate, flip_horizontal, flip_vertical, delete). Coordinates are top-left-origin page points. `image_save` and `image_edit`'s replace action return a clear error for Form artwork. + +Selection frames follow the visible portion of artwork after page and path clipping. Clicking +editable text takes precedence over overlapping artwork, including labels inside an image's +visible rectangle. Hidden image placements have no selection frame. Bezier extrema determine +curved-path frames. Hit testing evaluates the actual clipping paths with their nonzero or +even-odd fill rule, so holes and empty corners do not intercept clicks. Intersections of several +curved clips can still have a loose bounding frame. Dragging the visible frame moves or resizes +the full placement by the same change; resizing keeps the opposite corner fixed, including +horizontal-only or vertical-only corner drags, and grabbing a grip slightly off-centre does not +jump. A selected corner keeps control of its drag even over a text label; the drag previews one +frame and applies one undoable edit on release. A clip that belongs to the image alone +(`q re W n cm Do Q`) moves with it; a clip shared with other content stays where it is. + +`image_hit` applies the same path and hole checks headlessly. `page_images` retains `rect` as +the full placement rectangle used by `image_edit` and adds `visible_rect` for selection (null +when fully clipped). `object_list` reports visible artwork bounds while keeping the original +source indexes. A selection frame does not create, remove or enlarge the PDF's clipping path. diff --git a/docs/gaps.md b/docs/gaps.md index 1c4ad963d..1cb988a5f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** patch (gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (gap 1 closed by #836; fixed issues removed from gaps 3, 6 and 16) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document-font subset completion and curved/shared image clips in the contributor fork; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Every known shortfall, one entry each, ranked by what it costs users. This is the work list: agents choose from the top unless the owner says otherwise, and prefer these over new P2/P3 checklist @@ -68,6 +67,25 @@ update the numbers. ### 3. Editing existing text is fragile on real files +- **Fork progress (2026-10-11):** compatible words and subset-font spaces form lines; + source fonts lacking a space glyph remain reusable; inline editing previews remove source + glyphs on a clone. Aligned visual rows now form mixed-style regions (including matrix-scaled headings), + with source-run styles preserved during editing and delete/save/reopen regressions. A private + real-file acceptance check and opt-in UI regression supplement synthetic coverage. + Embedded sfnt faces now render in the editor, including Mac format-6 subsets adapted in memory + through the PDF Unicode map. Source baselines remain stable; draft frames expand/shrink with + visible text, and `text_edit_layout` measures saved draft bounds without applying them. + Page/path-clipped artwork bounds and text-first hit testing prevent large cropped images from + blocking text. A separate private four-page regression opens all 73 detected text regions; + curve/hole hit testing now evaluates clipping paths and fill rules. A clip the image owns + (`q re W n cm Do Q`) moves with it; shared path clips stay where they are. + Full matching document fonts complete absent subset glyphs; explicit installed/file font + selection, TrueType/OpenType-CFF embedding, disjoint style ranges, marked paragraph boundaries + and single-face OpenType shaping/bidi have regression coverage. Actual Chinese keyboard + insertion/deletion and save/reopen renders are checked on all four private pages. + Legacy non-SFNT previews, mixed-font/style contextual runs, structure-tree fidelity and reliable + semantic grouping on every untagged PDF remain open. Estimates are unchanged. + - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping structure tags, find and replace in edit mode; vector and object editing; arrange and align. @@ -287,6 +305,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Contributor fork: mixed-style visual regions, source-font space reuse and a read-only textless background. Embedded sfnt source faces and Mac subset Unicode mappings keep edit baselines stable; live frames follow Apply/Save geometry as text grows or shrinks. Engine/tool/UI regressions and private all-page checks cover the changes; full matching font import, CFF, range styles, shaping and private Chinese typing checks added; legacy programs and general fidelity remain open; estimates unchanged | | 2026-10-11 | patch | Gap 2: 23 hayro-interpret and 10 hayro patches after axial and radial shadings became native gradients (backport of hayro 0.8) | | 2026-10-11 | minor | Closed gap 1 (#774, fixed by #836; numbers kept, its interop suite moved to gap 12); gap 6 lists the fixed #799–#821 batch; gap 3 and 16 updated for #844 (same-page move), #740 (column select) and #759 (1:1 touchpad scrolling) | | 2026-10-10 | minor | Removed the alpha markers: with the cross-app gate rule, gaps 1 and 8 are partial sub-cases, not blockers; stage alpha | diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d55ce85..d21aa941b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -83,7 +83,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | @@ -144,6 +144,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. | | 2026-10-11 | minor | M7: #594 ordinary paragraph drags validated in the Linux test environment with source-loss baseline failures, UI/engine regressions, native save/reopen and all eight CI gates. Estimates unchanged. | | 2026-10-11 | minor | M4: direct Organize overview, checkbox selection and individual page actions. Estimates unchanged. | +| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged | +| 2026-10-11 | minor | Contributor fork: compatible word/subset-space grouping, source-font space reuse and a read-only textless editor background. Mixed-style regions now covered by engine/tool/UI regressions and private real-file checks; embedded sfnt faces, stable baselines and live draft extents now covered; installed-font selection and unsupported source programs remain open; estimates unchanged | | 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), so gap 1 left rank 0 and convert and share (32–55 h, the gap's 8–15 h removed); organize, XFDF and multi-object move fixes (#817, #821, #809, #819, #844); the #816 panic and the #799–#821 batch fixed; rank 1 names web printing (Windows shipped, #756). Stage and readiness numbers not re-measured | | 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. | | 2026-10-11 | minor | M5: partial XFDF/FDF imports preserve existing replies and review statuses through save and export. Estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2716fc061..96b0ba6e1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced, scores not re-judged: #774, #756, #740, #759, #799–#821, #825, #844; earlier: readiness table with hours per audience; full number restated as the standard's additive weighted sum, 47% → 49%; earlier: second look: cross-cutting double count removed, mainstream-practitioner number added, core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document-font subset completion, source styles and curved/shared image clips in the contributor fork; closed issues synced, scores not re-judged: #774, #756, #740, #759, #799–#821, #825, #844; earlier: readiness table with hours per audience; full number restated as the standard's additive weighted sum, 47% → 49%; earlier: second look: cross-cutting double count removed, mainstream-practitioner number added, core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) The authoritative assessment of how close PdfCraft is to Acrobat Pro. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every shortfall one by one; the deep-dive checklists are @@ -292,6 +292,7 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged | | 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), #825 and the #799–#821 fix batch. Text only: scores, readiness and hours not re-judged | | 2026-10-10 | minor | Readiness table: hours to ≈ 95% per audience (full 780–1,400, mainstream 500–900, essentials 180–330). Full number restated as the standard's additive weighted sum over dimensions with written weights: 47% → 49%. Method aligned with the standard, no new evidence | | 2026-10-10 | minor | Added the essentials-user score (≈ 66%): weighted depth of 13 everyday features, discounted for launch, discoverability and opening others' files | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8dcf04ff8..6dfaeeddb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1829,9 +1829,9 @@ tier = "P0" milestone = "M7" status = "partial" commands = ["edit.edit_text"] -tools = ["text_lines", "text_paragraphs", "text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited"] -notes = "click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next" +tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools"] +notes = "click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow" @@ -1999,7 +1999,7 @@ status = "partial" commands = [] tools = ["text_lines", "text_paragraphs", "text_edit"] evidence = ["crates/edit/src/tests.rs::hebrew_lines_read_in_typing_order", "crates/edit/src/tests.rs::hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge", "crates/edit/src/tests.rs::hebrew_letters_the_page_font_lacks_use_the_hebrew_face", "crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip"] -notes = "Hebrew: lines and paragraphs read in typing order and are written back in drawing order, right-aligned by default; letters the page's font lacks use the craft-fonts Noto Sans Hebrew face as Type 3 glyphs. Japanese uses its Type 3 fallback. Missing: Hebrew points (niqqud) in replacement text, writing Arabic (shaping), right-to-left typing in the edit box (misc.rtl-ui), lines a producer draws word by word from the right, vertical CJK." +notes = "Hebrew: lines and paragraphs read in typing order and are written back in drawing order, right-aligned by default; letters the page's font lacks use the craft-fonts Noto Sans Hebrew face as Type 3 glyphs. Japanese uses its Type 3 fallback. Missing: Hebrew points (niqqud) in replacement text, writing Arabic (shaping), right-to-left typing in the edit box (misc.rtl-ui), lines a producer draws word by word from the right, vertical CJK. Also (#1116): bounded single-face existing-text engine coverage now exists (Arabic marks/ligatures/mixed bidi and explicit full-face Chinese subset completion), with private keyboard/save/reopen checks. General mixed-font/style contextual runs, bidi caret/selection, legacy programs and release font availability remain incomplete, so broader feature remains planned" [[feature]] id = "edit.rotated-text-editing" @@ -2081,9 +2081,9 @@ tier = "P0" milestone = "M7" status = "shipped" commands = ["edit.edit_text"] -tools = ["image_edit"] -evidence = ["crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"] -notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too" +tools = ["image_edit", "image_hit"] +evidence = ["crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"] +notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; an image's own `q re W n cm Do Q` clip moves with it (#1049), shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" [[feature]] id = "edit.image-extract"