mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
Merge track r3-t04
This commit is contained in:
commit
692f34fce2
80 files changed
+2447
-149
No files matched your search
Generated
+1
@@ -4235,6 +4235,7 @@ dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
+26
@@ -4,6 +4,10 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
|
||||
@@ -56,6 +60,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged)
|
||||
@@ -196,6 +201,15 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
|
||||
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
|
||||
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
|
||||
- **2026-10-23 (M11, optimizer resolution):** Image downsampling includes each page's `/UserUnit` scale, preserving images already below the physical resolution threshold, including shared nested forms.
|
||||
Synthetic measurement and optimization regressions cover direct and indirect units and page-local defaults.
|
||||
Estimates unchanged.
|
||||
- **2026-10-23 (M10, Word table widths):** Word export sizes each cell from the grid columns it spans, keeping unequal columns and cells after a merged cell consistent with the table grid.
|
||||
- **2026-10-23 (M10, table continuation text):** Office exporters retain text from table slots covered by another cell's span as paragraph text.
|
||||
Synthetic regressions check every input fragment survives exactly once in Word, HTML, RTF and PowerPoint, while ordinary superscripts stay in their table cells.
|
||||
- **2026-10-23 (M10, image export):** Soft-mask Decode intervals preserve partial opacity in Export all images and Save Image As.
|
||||
Synthetic regressions cover direct and indirect ranges, inverted/subrange alpha, packed masks, JPEG masks and unchanged image component Decode.
|
||||
|
||||
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
|
||||
- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
|
||||
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
|
||||
@@ -254,6 +268,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
|
||||
- **2026-10-18 (M12, #2589):** Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged.
|
||||
- **2026-10-18 (M7, #2592):** Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged.
|
||||
- **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged.
|
||||
- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged.
|
||||
- **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged.
|
||||
@@ -647,6 +662,10 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Export all images and Save Image As apply soft-mask Decode intervals to PNG alpha before resizing, including inverted/subrange, indirect, packed and JPEG masks. Estimates unchanged. |
|
||||
| 2026-10-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
|
||||
@@ -692,8 +711,15 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
|
||||
|
||||
- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
|
||||
|
||||
- 2026-10-18: M11 / #3227: mark target-version compatibility partial; document the unchecked newer-content limitation.
|
||||
|
||||
- **2026-10-18 (PR #3227 integration):** M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged.
|
||||
|
||||
- **2026-10-12 (i18n):** Albanian (`sq`, Shqip) added: 2,343-entry catalog, month and weekday names, Preferences entry; not yet reviewed by a native speaker.
|
||||
|
||||
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
|
||||
|
||||
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
|
||||
|
||||
- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.
|
||||
@@ -688,6 +688,10 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
pdfcraft_render::RenderWarning::ContentTruncated => {
|
||||
let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
|
||||
}
|
||||
pdfcraft_render::RenderWarning::ColourFallback => {
|
||||
let _ =
|
||||
writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1216,7 +1216,7 @@ impl Automation {
|
||||
}
|
||||
|
||||
fn doc_optimize(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset};
|
||||
use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset};
|
||||
let id = self.doc(a)?.id;
|
||||
let before = self.doc(a)?.bytes.len();
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
@@ -1224,6 +1224,10 @@ impl Automation {
|
||||
None => Preset::Standard.settings(),
|
||||
Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(),
|
||||
};
|
||||
if let Some(c) = a.opt_str("compatibility")? {
|
||||
settings.compatibility = Compatibility::from_name(c)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?;
|
||||
}
|
||||
let images = |key: &str, base: ImageSettings| -> Result<ImageSettings> {
|
||||
let Some(v) = a.get(key) else { return Ok(base) };
|
||||
let mut s = base;
|
||||
@@ -1257,6 +1261,8 @@ impl Automation {
|
||||
("discard_tags", &mut settings.discard_tags),
|
||||
("discard_print_settings", &mut settings.discard_print_settings),
|
||||
("flate_unencoded", &mut settings.flate_unencoded),
|
||||
("object_streams", &mut settings.object_streams),
|
||||
("lzw_to_flate", &mut settings.lzw_to_flate),
|
||||
("remove_invalid_links", &mut settings.remove_invalid_links),
|
||||
("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests),
|
||||
] {
|
||||
@@ -1279,9 +1285,24 @@ impl Automation {
|
||||
};
|
||||
let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
fn valid_version(v: &[u8]) -> Option<&str> {
|
||||
if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None }
|
||||
}
|
||||
let catalog_version = (settings.compatibility == Compatibility::Retain)
|
||||
.then(|| {
|
||||
let pw = self.doc(a).ok().and_then(|d| d.password.as_deref());
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?;
|
||||
let root = doc.root()?;
|
||||
doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned)
|
||||
})
|
||||
.flatten();
|
||||
let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version);
|
||||
let pdf_version = catalog_version.as_deref().or(header_version);
|
||||
let o = &r.optimize;
|
||||
Ok(json!({
|
||||
"path": path.to_string_lossy(),
|
||||
"pdf_version": pdf_version,
|
||||
"object_streams": settings.effective_object_streams(),
|
||||
"bytes_before": before,
|
||||
"bytes_after": bytes.len(),
|
||||
"merged_images": o.merged_images,
|
||||
@@ -1295,6 +1316,7 @@ impl Automation {
|
||||
"alternate_images": o.alternate_images,
|
||||
"tags_removed": o.tags_removed,
|
||||
"streams_compressed": o.streams_compressed,
|
||||
"lzw_streams": o.lzw_streams,
|
||||
"invalid_links": o.invalid_links,
|
||||
"invalid_bookmarks": o.invalid_bookmarks,
|
||||
"unreferenced_dests": o.unreferenced_dests,
|
||||
|
||||
@@ -1391,13 +1391,14 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
|
||||
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
|
||||
.cmd("optimize.advanced")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"path": { "type": "string" },
|
||||
"preset": { "type": "string", "enum": ["standard", "mobile"] },
|
||||
"compatibility": { "type": "string", "enum": ["retain", "1.4", "1.5", "1.6", "1.7", "2.0"] },
|
||||
"merge_fragments": { "type": "boolean" },
|
||||
"color": { "type": "object" },
|
||||
"gray": { "type": "object" },
|
||||
@@ -1406,6 +1407,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"discard_tags": { "type": "boolean" },
|
||||
"discard_print_settings": { "type": "boolean" },
|
||||
"flate_unencoded": { "type": "boolean" },
|
||||
"object_streams": { "type": "boolean" },
|
||||
"lzw_to_flate": { "type": "boolean" },
|
||||
"remove_invalid_links": { "type": "boolean" },
|
||||
"remove_unreferenced_dests": { "type": "boolean" },
|
||||
"discard": { "type": "array", "items": { "type": "string" } },
|
||||
|
||||
@@ -3003,6 +3003,43 @@ fn editing_existing_text_through_tools() {
|
||||
assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() {
|
||||
if pdfcraft_fonts::document_japanese_font().is_none() {
|
||||
eprintln!("skipping: built without craft-fonts");
|
||||
return;
|
||||
}
|
||||
let dir = workdir("styled-fallback");
|
||||
std::fs::write(
|
||||
dir.join("mixed.pdf"),
|
||||
helvetica_page(
|
||||
"BT /F1 12 Tf 14.4 TL 72 700 Td (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "mixed.pdf"}))["doc"].as_u64().unwrap();
|
||||
let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field.";
|
||||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":replacement,"width":520}));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"}));
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap();
|
||||
let page = pdfcraft_model::pages(&saved).swap_remove(0);
|
||||
let resources = saved.dict(page.dict.get(b"Resources").unwrap()).unwrap();
|
||||
let fonts = saved.dict(resources.get(b"Font").unwrap()).unwrap();
|
||||
let fallback_count = fonts.iter().filter(|(name, _)| name.starts_with(b"PCJp")).count();
|
||||
assert_eq!(fallback_count, 1, "one shared fallback font for the new word");
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert!(!page_text(&mut a, doc)[0].contains("шалом"));
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
assert!(page_text(&mut a, doc)[0].contains("шалом"));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
let lines = ok(&mut a, "text_lines", json!({"doc":reopened,"page":1}));
|
||||
let runs = lines["lines"].as_array().unwrap();
|
||||
assert!(runs.iter().any(|l| l["text"].as_str().is_some_and(|t| t.starts_with("The quick")) && l["font"] == "Helvetica"), "{lines}");
|
||||
assert_eq!(runs.iter().filter(|l| l["text"] == "шалом").count(), 1, "{lines}");
|
||||
assert!(page_text(&mut a, reopened)[0].contains("шалом"));
|
||||
}
|
||||
|
||||
/// #2593: a paragraph whose own font cannot show a character is written from PdfCraft's fallback
|
||||
/// faces. The Cyrillic the craft-fonts faces lack (Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ) comes from the
|
||||
/// bundled Inter face, so the edit is not refused even without `CRAFT_FONTS_DIR`, and the words
|
||||
@@ -5876,6 +5913,28 @@ fn optimizing_through_tools() {
|
||||
assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap());
|
||||
assert_eq!(reduced["written"], true);
|
||||
assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap());
|
||||
|
||||
// Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off).
|
||||
let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" }));
|
||||
assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}");
|
||||
let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap();
|
||||
assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm"));
|
||||
|
||||
let v20 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v20.pdf", "compatibility": "2.0", "object_streams": false }));
|
||||
assert_eq!((v20["pdf_version"].as_str(), v20["object_streams"].as_bool()), (Some("2.0"), Some(false)), "{v20}");
|
||||
assert!(std::fs::read(dir.join("v20.pdf")).unwrap().starts_with(b"%PDF-2.0\n"));
|
||||
|
||||
// In retain mode, a catalog /Version override is reported as the effective PDF version.
|
||||
let mut cos14 = pdfcraft_cos::Document::open(std::sync::Arc::new(v14_bytes)).unwrap();
|
||||
let root = cos14.root().unwrap();
|
||||
cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name("1.6"))).unwrap();
|
||||
let cat16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
|
||||
std::fs::write(dir.join("cat16.pdf"), &cat16).unwrap();
|
||||
let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap();
|
||||
let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false }));
|
||||
assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}");
|
||||
|
||||
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(matches!(
|
||||
a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
@@ -5883,6 +5942,56 @@ fn optimizing_through_tools() {
|
||||
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for
|
||||
/// fewer than 250 bytes, before the code width grows.
|
||||
fn lzw_literals(data: &[u8]) -> Vec<u8> {
|
||||
let codes: Vec<u16> = std::iter::once(256).chain(data.iter().map(|&b| u16::from(b))).chain([257]).collect();
|
||||
let bits: Vec<bool> = codes.iter().flat_map(|c| (0..9).rev().map(move |i| c >> i & 1 == 1)).collect();
|
||||
bits.chunks(8).map(|byte| byte.iter().enumerate().fold(0u8, |acc, (i, &b)| acc | (u8::from(b) << (7 - i)))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lzw_streams_are_stored_with_flate_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
|
||||
let dir = workdir("optimize-lzw");
|
||||
let ops = b"0.2 0.4 0.8 rg 72 72 200 100 re f 0 0 1 RG 72 300 m 400 500 l S".to_vec();
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let lzw = Stream::from_raw(Dict::from_iter([(b"Filter".to_vec(), Object::name("LZWDecode"))]), lzw_literals(&ops));
|
||||
assert_eq!(lzw.decoded_strict().unwrap(), ops);
|
||||
let content = doc.add(Object::Stream(lzw));
|
||||
let page = doc.add(Object::Dict(Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("Page")),
|
||||
(b"Parent".to_vec(), Object::Ref(pages)),
|
||||
(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])),
|
||||
(b"Contents".to_vec(), Object::Ref(content)),
|
||||
])));
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
d.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("lzw.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "lzw.pdf" }))["doc"].as_u64().unwrap();
|
||||
let filters = |name: &str| {
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap();
|
||||
let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let pages = saved.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap();
|
||||
let page = saved.get(pages.get(b"Kids").and_then(|k| k.as_array()?.first()?.as_ref()).unwrap()).as_dict().cloned().unwrap();
|
||||
match &*saved.get(page.reference(b"Contents").unwrap()) {
|
||||
Object::Stream(s) => (s.dict.get(b"Filter").cloned(), s.decoded_strict().unwrap()),
|
||||
_ => panic!("the page content is not a stream"),
|
||||
}
|
||||
};
|
||||
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "flate.pdf" }));
|
||||
assert_eq!(r["lzw_streams"].as_u64(), Some(1), "{r}");
|
||||
assert_eq!(filters("flate.pdf"), (Some(Object::name("FlateDecode")), ops.clone()));
|
||||
let off = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "lzw_to_flate": false }));
|
||||
assert_eq!(off["lzw_streams"].as_u64(), Some(0), "{off}");
|
||||
assert_eq!(filters("kept.pdf"), (Some(Object::name("LZWDecode")), ops));
|
||||
}
|
||||
|
||||
/// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px
|
||||
/// wide on a 72 pt square (600 ppi), as some PDF printers write it.
|
||||
fn printed_in_strips(rows: usize) -> Vec<u8> {
|
||||
|
||||
@@ -897,6 +897,11 @@ impl Document {
|
||||
&self.version
|
||||
}
|
||||
|
||||
/// Set the PDF header version (`"1.4"`, `"1.7"`, `"2.0"`, …) written by a full save.
|
||||
pub fn set_version(&mut self, version: impl Into<String>) {
|
||||
self.version = version.into();
|
||||
}
|
||||
|
||||
pub fn trailer(&self) -> &Dict {
|
||||
&self.trailer
|
||||
}
|
||||
|
||||
@@ -27,5 +27,7 @@ Every function returns a `pdfcraft_cos::Document`; the caller writes it.
|
||||
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
|
||||
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
|
||||
Missing, null or malformed Decode arrays keep the default mapping.
|
||||
The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited.
|
||||
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
|
||||
The existing 16-bit path still uses each sample's high byte.
|
||||
JPEG image Decode handling remains limited.
|
||||
ICC transforms and calibrated color conversion remain outside this export path.
|
||||
@@ -465,7 +465,7 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8
|
||||
Some(gray_plane(doc, m, w, h)?.into_iter().map(|v| 255 - v).collect())
|
||||
}
|
||||
|
||||
/// A one-component mask image as 8-bit values (its `/Decode` inversion applied), scaled to
|
||||
/// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to
|
||||
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000, JBIG2 and CCITT masks).
|
||||
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?;
|
||||
@@ -491,14 +491,11 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
|
||||
unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
|
||||
}
|
||||
};
|
||||
let invert = m
|
||||
.dict
|
||||
.get(b"Decode")
|
||||
.map(|d| doc.resolve(d))
|
||||
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
|
||||
.unwrap_or(false);
|
||||
if invert {
|
||||
px.iter_mut().for_each(|v| *v = 255 - *v);
|
||||
if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) {
|
||||
for value in &mut px {
|
||||
let t = f64::from(*value) / 255.0;
|
||||
*value = (((1.0 - t) * lo + t * hi).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
if (mw, mh) == (w, h) {
|
||||
return Some(px);
|
||||
|
||||
@@ -682,6 +682,74 @@ fn jpeg_images_with_a_soft_mask_export_as_png_with_alpha() {
|
||||
assert_eq!((out.images[0].extension, &out.images[0].data), ("jpg", &real_jpeg()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Both image export paths apply mask intervals independently of the image's component Decode.
|
||||
fn soft_mask_decode_ranges_become_exported_alpha() {
|
||||
for indirect in [false, true] {
|
||||
for (range, expected) in [
|
||||
([0.25, 0.75], [64, 128, 191]),
|
||||
([0.75, 0.25], [191, 127, 64]),
|
||||
([1.0, 0.0], [255, 127, 0]),
|
||||
([-1.0, 2.0], [0, 129, 255]),
|
||||
([0.5, 0.5], [128, 128, 128]),
|
||||
] {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let endpoints = range.into_iter().map(|v| {
|
||||
let value = Object::Real(v);
|
||||
if indirect { Object::Ref(doc.add(value)) } else { value }
|
||||
});
|
||||
let decode = Object::Array(endpoints.collect());
|
||||
let decode = if indirect { Object::Ref(doc.add(decode)) } else { decode };
|
||||
let mut mask = image_dict(3, 1, "DeviceGray", 8);
|
||||
mask.set(b"Decode".to_vec(), decode);
|
||||
let mask = doc.add(Object::Stream(Stream::from_raw(mask, vec![0, 128, 255])));
|
||||
let mut image = image_dict(3, 1, "DeviceRGB", 8);
|
||||
image.set(b"SMask".to_vec(), Object::Ref(mask));
|
||||
image.set(b"Decode".to_vec(), decode_array(&[1.0, 0.0, 0.0, 1.0, 0.0, 1.0]));
|
||||
Stream::from_raw(image, [0, 64, 255].repeat(3))
|
||||
});
|
||||
let doc = reopen(&doc);
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
|
||||
assert_eq!(out.images.len(), 1);
|
||||
let pixels = exported_png_pixels(&out.images[0].data);
|
||||
assert_eq!(alphas(&pixels), expected, "Decode {range:?}, indirect={indirect}");
|
||||
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 64, 255]), "image component Decode stays independent");
|
||||
let (ext, bytes) = image_file(&doc, out.images[0].object).unwrap();
|
||||
assert_eq!(ext, "png");
|
||||
assert_eq!(exported_png_pixels(&bytes), pixels, "Save Image As and Export all images agree");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Packed and JPEG samples map to opacity before resizing to their image's dimensions.
|
||||
fn soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing() {
|
||||
for jpeg in [false, true] {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mask = if jpeg {
|
||||
let mut mask = image_dict(4, 2, "DeviceGray", 8);
|
||||
mask.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
mask.set(b"Decode".to_vec(), decode_array(&[0.25, 0.25]));
|
||||
Stream::from_raw(mask, real_jpeg())
|
||||
} else {
|
||||
let mut mask = image_dict(2, 1, "DeviceGray", 1);
|
||||
mask.set(b"Decode".to_vec(), decode_array(&[-1.0, 2.0]));
|
||||
Stream::from_raw(mask, vec![0b0100_0000])
|
||||
};
|
||||
let mask = doc.add(Object::Stream(mask));
|
||||
let mut image = image_dict(4, 2, "DeviceRGB", 8);
|
||||
image.set(b"SMask".to_vec(), Object::Ref(mask));
|
||||
Stream::from_raw(image, vec![10; 24])
|
||||
});
|
||||
// Clipping before resizing differs from resizing first, then clipping to [0, 255].
|
||||
let expected = if jpeg { vec![64; 8] } else { vec![0, 64, 191, 255, 0, 64, 191, 255] };
|
||||
assert_eq!(alphas(&exported_rgba(&reopen(&doc)).1), expected, "jpeg={jpeg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha() {
|
||||
// A 2×1 soft mask on a 4×2 image is scaled to the image's size.
|
||||
|
||||
@@ -1731,6 +1731,214 @@ fn japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts()
|
||||
assert_eq!(glyphs, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_paragraph_batches_cyrillic_without_changing_source_fonts() {
|
||||
if without_craft_fonts("styled_paragraph_batches_cyrillic_without_changing_source_fonts") {
|
||||
return;
|
||||
}
|
||||
let mut doc = text_page(
|
||||
"BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET",
|
||||
);
|
||||
let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field.";
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(replacement), &text::BlockStyle { width: Some(520.0), ..Default::default() }).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(fallback_fonts(&doc).len(), 1, "the five letters must share one font");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.starts_with("The quick brown fox")), "{lines:?}");
|
||||
let fallback: Vec<_> = lines.iter().filter(|l| l.font.starts_with("PCJp")).collect();
|
||||
assert_eq!(fallback.len(), 1, "one word must be one text run: {fallback:?}");
|
||||
assert_eq!(fallback[0].text, "шалом");
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<String>().replace(' ', ""), replacement.replace([' ', '\n'], ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically() {
|
||||
let replacement = format!("{}\nLatin", covered_japanese_chars(300));
|
||||
let mut doc = text_page("BT /F1 12 Tf 14.4 TL 72 700 Td (Original) Tj T* (Latin) Tj ET");
|
||||
let original = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
if without_craft_fonts("styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically") {
|
||||
assert!(text::replace_block(&mut doc, 0, 0, &replacement).is_err());
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original);
|
||||
return;
|
||||
}
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&replacement), &text::BlockStyle { width: Some(14000.0), ..Default::default() }).unwrap();
|
||||
let reopened = reopen(&doc);
|
||||
let fonts = fallback_fonts(&reopened);
|
||||
assert_eq!(fonts.len(), 2, "300 glyphs need two fonts, not an error at glyph 241");
|
||||
let counts: Vec<_> = fonts.iter().map(|(_, f)| reopened.dict(f.get(b"CharProcs").unwrap()).unwrap().len()).collect();
|
||||
assert_eq!(counts.iter().sum::<usize>(), 300);
|
||||
assert!(counts.iter().all(|n| *n <= 240));
|
||||
assert_eq!(text::text_lines(&reopened, 0).unwrap().iter().map(|l| l.text.as_str()).collect::<String>(), replacement.replace('\n', ""));
|
||||
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let snapshot = doc.clone();
|
||||
let err = text::replace_block(&mut doc, 0, 0, &format!("{replacement}\u{10ffff}")).unwrap_err();
|
||||
assert!(err.to_string().contains("U+10FFFF"), "{err}");
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
|
||||
assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_fallback_batches_keep_face_choices_and_range_styles() {
|
||||
if without_craft_fonts("styled_fallback_batches_keep_face_choices_and_range_styles") {
|
||||
return;
|
||||
}
|
||||
if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
|
||||
eprintln!("skipping: no bold Gothic face");
|
||||
return;
|
||||
}
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET");
|
||||
let range = |range, family, bold| text::RangeStyle {
|
||||
range,
|
||||
family: Some((family, bold, false)),
|
||||
resource: None,
|
||||
size: None,
|
||||
color: None,
|
||||
underline: None,
|
||||
char_spacing: None,
|
||||
scale: None,
|
||||
};
|
||||
let style = text::BlockStyle {
|
||||
width: Some(520.0),
|
||||
ranges: vec![
|
||||
range(3..6, added::Family::Times, false),
|
||||
range(6..9, added::Family::Helvetica, true),
|
||||
range(9..11, added::Family::Times, false),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let original = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let err = text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin\u{10ffff}"), &style).unwrap_err();
|
||||
assert!(err.to_string().contains("U+10FFFF"), "{err}");
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "all faces must validate before writing fallback objects");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin"), &style).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let fonts = fallback_fonts(&doc);
|
||||
let mut summary: Vec<_> = fonts
|
||||
.iter()
|
||||
.map(|(_, f)| {
|
||||
let face = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font;
|
||||
let count = doc.dict(f.get(b"CharProcs").unwrap()).unwrap().len();
|
||||
(face, count)
|
||||
})
|
||||
.collect();
|
||||
summary.sort();
|
||||
assert_eq!(
|
||||
summary,
|
||||
[
|
||||
("BIZUDMincho-Regular".into(), 1),
|
||||
("BIZUDPGothic-Bold".into(), 1),
|
||||
("BIZUDPGothic-Regular".into(), 2),
|
||||
("ShipporiMincho-Regular".into(), 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::<String>(), "見見見ш見шLatin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_fallback_budget_is_enforced_before_objects_are_written() {
|
||||
let glyphs = covered_japanese_chars(65);
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET");
|
||||
let original = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let snapshot = doc.clone();
|
||||
let style = text::BlockStyle { width: Some(14000.0), ..Default::default() };
|
||||
let over_budget = format!("{glyphs}\nLatin");
|
||||
let err = text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&over_budget), &style, 1).unwrap_err().to_string();
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "refusal must add no objects");
|
||||
assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), original);
|
||||
if without_craft_fonts("styled_fallback_budget_is_enforced_before_objects_are_written") {
|
||||
assert!(err.contains("CRAFT_FONTS_DIR"), "{err}");
|
||||
return;
|
||||
}
|
||||
assert!(err.contains("more than 64 fallback glyphs"), "{err}");
|
||||
// One glyph per font gives a small, real budget of 64 distinct glyphs. At the
|
||||
// boundary a repeated glyph is free: this is not a limit on text length.
|
||||
let at_budget: String = glyphs.chars().take(64).chain(glyphs.chars().take(1)).collect();
|
||||
text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&format!("{at_budget}\nLatin")), &style, 1).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(fallback_fonts(&doc).len(), 64);
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::<String>(), format!("{at_budget}Latin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_fallback_same_base_font_and_character_keep_both_weights() {
|
||||
if without_craft_fonts("styled_fallback_same_base_font_and_character_keep_both_weights") {
|
||||
return;
|
||||
}
|
||||
if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
|
||||
eprintln!("skipping: no bold Gothic face");
|
||||
return;
|
||||
}
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (alpha ) Tj /F2 12 Tf (beta ) Tj ET");
|
||||
let source = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict().unwrap().clone();
|
||||
for (object, weight) in [(5, 400), (6, 700)] {
|
||||
let mut font = source.clone();
|
||||
// The same BaseFont can have different descriptor weights. Changing a
|
||||
// RangeStyle family would also rename BaseFont and miss this cache key.
|
||||
font.set(b"BaseFont".to_vec(), Object::name("NeutralFace"));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontName".to_vec(), Object::name("NeutralFace"));
|
||||
descriptor.set(b"FontWeight".to_vec(), Object::Int(weight));
|
||||
font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
doc.set(pdfcraft_cos::ObjRef::new(object, 0), Object::Dict(font));
|
||||
}
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0].base_font, lines[1].base_font);
|
||||
assert_eq!((lines[0].bold, lines[1].bold), (false, true));
|
||||
// Unchanged words anchor each inserted copy of the same letter in its own source style.
|
||||
let block = text::text_blocks(&doc, 0).unwrap().remove(0);
|
||||
let mut inherited = Vec::new();
|
||||
for (range, source) in block.edited_spans("alpha ш beta ш") {
|
||||
for _ in "alpha ш beta ш".get(range).unwrap().chars().filter(|ch| *ch == 'ш') {
|
||||
inherited.push(lines[source].bold);
|
||||
}
|
||||
}
|
||||
assert_eq!(inherited, [false, true], "the fixture must exercise both weights of the same BaseFont");
|
||||
text::replace_block(&mut doc, 0, 0, "alpha ш beta ш").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let fonts = fallback_fonts(&doc);
|
||||
let mut names: Vec<_> = fonts.iter().map(|(_, f)| pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["BIZUDPGothic-Bold", "BIZUDPGothic-Regular"]);
|
||||
let outlines: Vec<_> = fonts
|
||||
.iter()
|
||||
.map(|(_, f)| {
|
||||
let procs = doc.dict(f.get(b"CharProcs").unwrap()).unwrap();
|
||||
assert_eq!(procs.len(), 1, "each weight needs just the shared letter");
|
||||
stream_bytes(&doc, procs.iter().next().unwrap().1).unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert_ne!(outlines[0], outlines[1], "weight must change the outline, not only the descriptor");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<String>(), "alpha ш beta ш");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_fallback_shards_keep_text_advances_and_alignment() {
|
||||
if without_craft_fonts("styled_fallback_shards_keep_text_advances_and_alignment") {
|
||||
return;
|
||||
}
|
||||
let source = "BT /F1 12 Tf 0.5 Tc 5 Tw 80 Tz 14.4 TL 72 700 Td (Original row) Tj T* (Second row) Tj ET";
|
||||
let style =
|
||||
text::BlockStyle { width: Some(520.0), align: Some(added::Align::Center), char_spacing: Some(0.7), scale: Some(80.0), ..Default::default() };
|
||||
let mut whole = text_page(source);
|
||||
let mut split = text_page(source);
|
||||
let replacement = "Latin шалом ш\nSecond row";
|
||||
text::rewrite_block_with_limit(&mut whole, 0, 0, Some(replacement), &style, 240).unwrap();
|
||||
text::rewrite_block_with_limit(&mut split, 0, 0, Some(replacement), &style, 2).unwrap();
|
||||
let (whole, split) = (reopen(&whole), reopen(&split));
|
||||
assert_eq!(fallback_fonts(&whole).len(), 1);
|
||||
assert_eq!(fallback_fonts(&split).len(), 3);
|
||||
let (one, many) = (text::text_lines(&whole, 0).unwrap(), text::text_lines(&split, 0).unwrap());
|
||||
assert_eq!(one.len(), many.len(), "{one:?} vs {many:?}");
|
||||
for (a, b) in one.iter().zip(&many) {
|
||||
assert_eq!(a.text, b.text);
|
||||
for (a, b) in a.rect.iter().zip(b.rect) {
|
||||
assert!((a - b).abs() < 0.002, "sharding must preserve alignment and advances: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A replacement whose last glyph no face draws (in a later font) changes nothing: neither the
|
||||
/// document nor a snapshot taken before the edit, which is what undo restores.
|
||||
#[test]
|
||||
|
||||
+15
-2
@@ -2252,6 +2252,19 @@ fn type3_font(
|
||||
.map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?;
|
||||
glyphs.push(Type3Glyph { code, ch, path, width });
|
||||
}
|
||||
Ok(write_type3_glyphs(doc, fonts_res, face, hebrew, glyphs, per_font))
|
||||
}
|
||||
|
||||
/// Write already validated outlines; callers can prepare every face before adding any objects.
|
||||
fn write_type3_glyphs(
|
||||
doc: &mut Document,
|
||||
fonts_res: &mut Dict,
|
||||
face: &CraftFont,
|
||||
hebrew: bool,
|
||||
glyphs: Vec<Type3Glyph>,
|
||||
per_font: usize,
|
||||
) -> Type3Fallback {
|
||||
let per_font = per_font.clamp(1, MAX_TYPE3_GLYPHS);
|
||||
let mut codes = HashMap::with_capacity(glyphs.len());
|
||||
let mut widths = Vec::with_capacity(glyphs.len());
|
||||
let mut names = Vec::new();
|
||||
@@ -2267,7 +2280,7 @@ fn type3_font(
|
||||
}
|
||||
names.push(add_type3_font(doc, fonts_res, face, hebrew, group));
|
||||
}
|
||||
Ok(Type3Fallback { family: face.family, names, widths, codes })
|
||||
Type3Fallback { family: face.family, names, widths, codes }
|
||||
}
|
||||
|
||||
/// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource
|
||||
@@ -2915,7 +2928,7 @@ fn rewrite_block_from(
|
||||
&& style.family.is_none()
|
||||
&& style.bold.is_none())
|
||||
{
|
||||
return rewrite_styled_block(doc, page, &b, &lines, text, style);
|
||||
return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs);
|
||||
}
|
||||
let streams = content_streams(doc, &p.dict)?;
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces.
|
||||
// A glyph budget worth 64 full batches (15,360 glyphs with the production batch size).
|
||||
// Lowering the internal batch size for tests lowers the budget by the same ratio.
|
||||
const MAX_STYLED_FALLBACK_BATCHES: usize = 64;
|
||||
|
||||
struct FallbackGroup {
|
||||
face: &'static CraftFont,
|
||||
seen: std::collections::HashSet<char>,
|
||||
glyphs: Vec<Type3Glyph>,
|
||||
positions: Vec<usize>,
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -400,6 +412,7 @@ pub(super) fn rewrite_styled_block(
|
||||
lines: &[TextLine],
|
||||
text: Option<&str>,
|
||||
style: &BlockStyle,
|
||||
max_glyphs: usize,
|
||||
) -> Result<LineEdit, EditError> {
|
||||
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 {
|
||||
@@ -542,7 +555,11 @@ pub(super) fn rewrite_styled_block(
|
||||
// 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 fallback_groups: Vec<FallbackGroup> = Vec::new();
|
||||
let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new();
|
||||
let mut fallback_glyphs = 0_usize;
|
||||
let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS);
|
||||
let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES);
|
||||
let mut full_faces: HashMap<String, String> = HashMap::new();
|
||||
let mut programs: HashMap<String, Option<std::sync::Arc<Vec<u8>>>> = HashMap::new();
|
||||
for (range, index) in styled_spans {
|
||||
@@ -614,24 +631,40 @@ pub(super) fn rewrite_styled_block(
|
||||
} 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;
|
||||
let key = (source.base_font.clone(), source.bold, ch);
|
||||
let group_index = if let Some(index) = fallback_names.get(&key) {
|
||||
*index
|
||||
} else {
|
||||
// Keep the old per-character face choice: a serif Japanese character
|
||||
// and a Cyrillic character may need different faces even in one style.
|
||||
// Hebrew uses the separate RTL path before reaching this writer.
|
||||
let (faces, _) = type3_faces(&s, family, source.bold)?;
|
||||
let face =
|
||||
fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?;
|
||||
let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| {
|
||||
let index = fallback_groups.len();
|
||||
fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() });
|
||||
index
|
||||
});
|
||||
fallback_names.insert(key, index);
|
||||
index
|
||||
};
|
||||
let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?;
|
||||
if group.seen.insert(ch) {
|
||||
if fallback_glyphs >= fallback_budget {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font"
|
||||
)));
|
||||
}
|
||||
let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?;
|
||||
let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1))
|
||||
.map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?;
|
||||
group.glyphs.push(Type3Glyph { code, ch, path, width });
|
||||
fallback_glyphs = fallback_glyphs.saturating_add(1);
|
||||
}
|
||||
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
|
||||
group.positions.push(chars.len());
|
||||
// Filled from the prepared font/code map before measuring or drawing.
|
||||
String::new()
|
||||
} else {
|
||||
let base = family.base_font(source.bold, source.italic);
|
||||
let name = format!("PCEd{}", base.replace('-', ""));
|
||||
@@ -649,6 +682,24 @@ pub(super) fn rewrite_styled_block(
|
||||
chars.push((ch, index, name));
|
||||
}
|
||||
}
|
||||
// All fallback outlines have now succeeded. Batch by actual face, preserving source
|
||||
// style indices; the existing writer then coalesces adjacent equal-font/style characters.
|
||||
for group in fallback_groups {
|
||||
let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font);
|
||||
for name in &fallback.names {
|
||||
let font = fonts
|
||||
.get(name.as_bytes())
|
||||
.and_then(|f| doc.dict(f))
|
||||
.ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?;
|
||||
metrics.insert(name.clone(), Metrics::from_dict(doc, &font));
|
||||
}
|
||||
for position in group.positions {
|
||||
let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?;
|
||||
let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?;
|
||||
item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?;
|
||||
}
|
||||
substituted = Some(fallback.family.to_owned());
|
||||
}
|
||||
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)
|
||||
};
|
||||
|
||||
@@ -68,7 +68,11 @@ impl Session {
|
||||
cos: editor.cos.clone(),
|
||||
settings: settings.clone(),
|
||||
discard: discard.to_vec(),
|
||||
opts: SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() },
|
||||
opts: SaveOptions {
|
||||
mod_date: self.now().map(pdfcraft_cos::pdf_date),
|
||||
object_streams: settings.effective_object_streams(),
|
||||
..SaveOptions::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+78
-10
@@ -256,7 +256,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
|
||||
}
|
||||
let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL;
|
||||
let mut rows_out = Vec::new();
|
||||
let mut slots: Vec<Option<Cell>> = vec![None; ncols];
|
||||
let mut slots: Vec<Option<(Cell, Vec<usize>)>> = vec![None; ncols];
|
||||
for row in run.drain(..) {
|
||||
for slot in slots.iter_mut() {
|
||||
*slot = None;
|
||||
@@ -266,20 +266,28 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
|
||||
let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue };
|
||||
let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1;
|
||||
let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span };
|
||||
match &mut slots[slot] {
|
||||
Some(c) => {
|
||||
match slots.get_mut(slot) {
|
||||
Some(Some((c, sources))) => {
|
||||
c.text.push(' ');
|
||||
c.text.push_str(&cell.text);
|
||||
sources.push(bi);
|
||||
}
|
||||
None => slots[slot] = Some(cell),
|
||||
Some(empty @ None) => *empty = Some((cell, vec![bi])),
|
||||
None => continue,
|
||||
}
|
||||
consumed[bi] = true;
|
||||
}
|
||||
let mut materialized = Vec::with_capacity(ncols);
|
||||
let mut column = 0;
|
||||
while column < ncols {
|
||||
match slots[column].take() {
|
||||
Some(mut cell) => {
|
||||
match slots.get_mut(column).and_then(Option::take) {
|
||||
Some((mut cell, sources)) => {
|
||||
// A spanning cell can skip later slots. Only consume source blocks
|
||||
// when their text is emitted, leaving covered text in paragraph flow.
|
||||
for bi in sources {
|
||||
if let Some(taken) = consumed.get_mut(bi) {
|
||||
*taken = true;
|
||||
}
|
||||
}
|
||||
cell.span = cell.span.min(ncols - column).max(1);
|
||||
column += cell.span;
|
||||
materialized.push(cell);
|
||||
@@ -517,17 +525,18 @@ fn docx_table(t: &Table) -> String {
|
||||
s.push_str("</w:tblGrid>");
|
||||
for row in &t.rows {
|
||||
s.push_str("<w:tr>");
|
||||
let mut column = 0usize;
|
||||
for c in row {
|
||||
let span = if c.span > 1 { format!("<w:gridSpan w:val=\"{}\"/>", c.span) } else { String::new() };
|
||||
let w: i64 = widths.iter().take(c.span.min(widths.len())).sum();
|
||||
let w: i64 = widths.iter().skip(column).take(c.span).sum();
|
||||
s.push_str(&format!(
|
||||
"<w:tc><w:tcPr><w:tcW w:w=\"{w}\" w:type=\"dxa\"/>{span}</w:tcPr><w:p>{}</w:p></w:tc>",
|
||||
run_xml(&c.text, c.size, c.bold, c.italic, c.color)
|
||||
));
|
||||
column = column.saturating_add(c.span);
|
||||
}
|
||||
// Pad the grid so every row covers all columns (Word rejects short rows).
|
||||
let used: usize = row.iter().map(|c| c.span).sum();
|
||||
for _ in used..t.cols.len() {
|
||||
for _ in column..t.cols.len() {
|
||||
s.push_str("<w:tc><w:tcPr><w:tcW w:w=\"0\" w:type=\"auto\"/></w:tcPr><w:p/></w:tc>");
|
||||
}
|
||||
s.push_str("</w:tr>");
|
||||
@@ -1040,6 +1049,27 @@ mod tests {
|
||||
assert!(xml.contains("<w:insideH w:val=\"nil\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_table_cell_widths_follow_the_columns_they_span() {
|
||||
let cell = |text: &str, span| Cell { text: text.into(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span };
|
||||
let table = Table {
|
||||
rect: [0.0, 0.0, 300.0, 100.0],
|
||||
cols: vec![0.0, 50.0, 150.0],
|
||||
rows: vec![
|
||||
vec![cell("First", 1), cell("Second", 1), cell("Third", 1)],
|
||||
vec![cell("First", 1), cell("Last two", 2)],
|
||||
vec![cell("First two", 2), cell("Third", 1)],
|
||||
vec![cell("All", 3)],
|
||||
vec![cell("First", 1)],
|
||||
],
|
||||
};
|
||||
let xml = docx_table(&table);
|
||||
assert!(xml.contains("<w:gridCol w:w=\"1000\"/><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"3000\"/>"), "{xml}");
|
||||
let widths: Vec<Vec<&str>> =
|
||||
xml.split("<w:tr>").skip(1).map(|row| row.split("<w:tcW w:w=\"").skip(1).map(|cell| cell.split('"').next().unwrap()).collect()).collect();
|
||||
assert_eq!(widths, vec![vec!["1000", "2000", "3000"], vec!["1000", "5000"], vec!["3000", "3000"], vec!["6000"], vec!["1000", "0", "0"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_keeps_a_page_with_a_clear_image_and_big_title_in_flow() {
|
||||
// page() has a picture between paragraphs and a 24 pt title over 11 pt body text, but
|
||||
@@ -1392,6 +1422,40 @@ mod tests {
|
||||
assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}");
|
||||
}
|
||||
|
||||
/// A continuation can drift into a grid column covered by the preceding cell's span.
|
||||
/// It must remain available as paragraph text if that slot never becomes an output cell.
|
||||
#[test]
|
||||
fn table_spans_do_not_consume_text_they_do_not_emit() {
|
||||
let mut blocks = vec![
|
||||
Block { size: 12.0, ..block("ABCDEFGHIJK", 72.1, 697.0, 151.3, 710.8) },
|
||||
Block { size: 12.0, color: [0.0, 0.0, 1.0], ..block("LOST", 150.4, 697.0, 179.2, 710.8) },
|
||||
Block { size: 12.0, ..block("R0", 250.0, 697.0, 264.4, 710.8) },
|
||||
];
|
||||
for row in 1..=4 {
|
||||
let y = 697.0 - f64::from(row) * 50.0;
|
||||
for (text, left, right) in [(format!("L{row}"), 72.1, 86.5), (format!("M{row}"), 146.4, 160.8), (format!("R{row}"), 250.0, 264.4)] {
|
||||
blocks.push(Block { size: 12.0, ..block(&text, left, y, right, y + 13.8) });
|
||||
}
|
||||
}
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() };
|
||||
let (tables, consumed) = tables(&p.blocks);
|
||||
assert_eq!(tables.len(), 1);
|
||||
assert_eq!(tables[0].rows[0][0].span, 2);
|
||||
assert!(!consumed[1], "a covered slot did not emit its text");
|
||||
assert!(consumed.iter().enumerate().all(|(index, used)| index == 1 || *used));
|
||||
let outputs = [
|
||||
html(std::slice::from_ref(&p), "Continuations"),
|
||||
part(&docx(std::slice::from_ref(&p), "Continuations"), "word/document.xml"),
|
||||
rtf(std::slice::from_ref(&p)),
|
||||
part(&pptx(std::slice::from_ref(&p), "Continuations"), "ppt/slides/slide1.xml"),
|
||||
];
|
||||
for output in outputs {
|
||||
for b in &p.blocks {
|
||||
assert_eq!(output.matches(&b.text).count(), 1, "{} must survive exactly once", b.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table
|
||||
/// around it stays a table.
|
||||
#[test]
|
||||
@@ -1407,6 +1471,10 @@ mod tests {
|
||||
let h = html(std::slice::from_ref(&p), "Prizes");
|
||||
assert!(h.contains("<table>"), "the prize table was lost: {h}");
|
||||
assert!(h.contains("30,000") && h.contains("15,000"), "{h}");
|
||||
let (tables, consumed) = tables(&p.blocks);
|
||||
assert!(consumed.iter().all(|used| *used), "ordinary table continuations stay in their cells");
|
||||
let cells: Vec<&str> = tables.iter().flat_map(|t| &t.rows).flatten().map(|c| c.text.as_str()).collect();
|
||||
assert!(["st", "nd", "rd", "th"].iter().all(|suffix| cells.contains(suffix)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-filters = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-annot = { workspace = true }
|
||||
|
||||
@@ -21,7 +21,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary
|
||||
policy, so trailing comments, split operands and graphics state keep their meaning after merging.
|
||||
- **Images:** the effective resolution of each image is measured where pages draw it (the CTM
|
||||
through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be
|
||||
through form XObjects, up to 12 deep; the smallest over all uses).
|
||||
Page-local `/UserUnit` scaling is included, so large-format pages are measured in physical inches.
|
||||
If a use can't be
|
||||
measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a
|
||||
threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and
|
||||
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image.
|
||||
@@ -30,10 +32,23 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
|
||||
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
|
||||
layer, that layer is removed when the result is smaller (the inner filters keep their encoded
|
||||
bytes). Streams with `/DecodeParms`, images and object streams are left alone.
|
||||
bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed
|
||||
streams, content and images alike, are decoded and stored with Flate instead (Acrobat's "In
|
||||
streams that use LZW encoding, use Flate instead"): a predictor carries over to Flate without
|
||||
`EarlyChange`, filters applied before the LZW one go with it and those after it keep their
|
||||
bytes. A stream whose LZW data doesn't decode cleanly is left as it is. Not yet: inline images.
|
||||
|
||||
The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories,
|
||||
then this, then merges identical objects and writes a full, compressed save
|
||||
(`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a
|
||||
progress bar). JPEG decoding and
|
||||
encoding and resampling come from the `image` crate.
|
||||
|
||||
## Target PDF version: partial compatibility
|
||||
|
||||
The target selects the header version, clears the catalog `/Version`, controls object
|
||||
streams (disabled for PDF 1.4), and checks encryption requirements. It does **not**
|
||||
detect or convert content newer than the target: optional content, JPEG 2000 images,
|
||||
16-bit images, OpenType fonts and PDF 2.0 features can remain. A PDF 1.4 header
|
||||
therefore does not guarantee compatibility with PDF 1.4 readers or validators.
|
||||
`optimize.compatibility-level` remains partial until content compatibility is checked.
|
||||
@@ -158,15 +158,18 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef
|
||||
out: HashMap::new(),
|
||||
};
|
||||
for p in pages {
|
||||
let Some(d) = doc.get(*p).as_dict().cloned() else { continue };
|
||||
let Some(contents) = d.get(b"Contents") else { continue };
|
||||
let Some(dict) = doc.get(*p).as_dict().cloned() else { continue };
|
||||
let page = pdfcraft_model::Page { obj: *p, dict };
|
||||
let Some(contents) = page.dict.get(b"Contents") else { continue };
|
||||
let res = page_resources(doc, *p).unwrap_or_default();
|
||||
walk.budget = DecodeBudget::page();
|
||||
let Some(data) = content_bytes(doc, contents, &mut walk.budget) else {
|
||||
walk.complete = false;
|
||||
continue;
|
||||
};
|
||||
walk.content(&data, &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
// Content coordinates are in page units; image resolution is in physical inches.
|
||||
let unit = page.user_unit(doc);
|
||||
walk.content(&data, &res, Matrix([unit, 0.0, 0.0, unit, 0.0, 0.0]));
|
||||
}
|
||||
if !walk.complete {
|
||||
walk.out.values_mut().for_each(|v| *v = f64::INFINITY);
|
||||
|
||||
+229
-2
@@ -14,7 +14,7 @@
|
||||
//! print settings.
|
||||
//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted
|
||||
//! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or
|
||||
//! ASCIIHex layer from streams that have one.
|
||||
//! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead.
|
||||
//!
|
||||
//! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form
|
||||
//! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine
|
||||
@@ -30,7 +30,8 @@ mod unused;
|
||||
|
||||
pub use audit::{SpaceCategory, SpaceUse, audit_space};
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object, Stream, serialize};
|
||||
use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize};
|
||||
use pdfcraft_filters::{Filter, Params};
|
||||
|
||||
pub use images::effective_resolutions;
|
||||
|
||||
@@ -38,12 +39,62 @@ pub use images::effective_resolutions;
|
||||
pub enum OptimizeError {
|
||||
#[error("the document has no page tree")]
|
||||
NoPages,
|
||||
#[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")]
|
||||
IncompatibleEncryption { required: &'static str, chosen: &'static str },
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
#[error("the optimization was cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// The target header version and object-stream policy for an optimized copy.
|
||||
///
|
||||
/// Partial compatibility only: encryption requirements are checked, but content newer than
|
||||
/// the target is neither detected nor converted. A lower header does not guarantee that
|
||||
/// older readers can read the document.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Compatibility {
|
||||
/// Keep the document's version (upgraded to 1.5 when object streams are written).
|
||||
#[default]
|
||||
Retain,
|
||||
Pdf1_4,
|
||||
Pdf1_5,
|
||||
Pdf1_6,
|
||||
Pdf1_7,
|
||||
Pdf2_0,
|
||||
}
|
||||
|
||||
impl Compatibility {
|
||||
pub const ALL: [Compatibility; 6] =
|
||||
[Compatibility::Retain, Compatibility::Pdf1_4, Compatibility::Pdf1_5, Compatibility::Pdf1_6, Compatibility::Pdf1_7, Compatibility::Pdf2_0];
|
||||
|
||||
/// The name tools use (`"retain"`, `"1.4"`, `"1.5"`, `"1.6"`, `"1.7"`, `"2.0"`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Compatibility::Retain => "retain",
|
||||
Compatibility::Pdf1_4 => "1.4",
|
||||
Compatibility::Pdf1_5 => "1.5",
|
||||
Compatibility::Pdf1_6 => "1.6",
|
||||
Compatibility::Pdf1_7 => "1.7",
|
||||
Compatibility::Pdf2_0 => "2.0",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Compatibility> {
|
||||
let trimmed = name.trim();
|
||||
let stripped = if trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("pdf ")) { &trimmed[4..] } else { trimmed };
|
||||
Compatibility::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(stripped))
|
||||
}
|
||||
|
||||
/// The three-character `%PDF-` header version, when a target version is chosen.
|
||||
pub fn version(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Compatibility::Retain => None,
|
||||
_ => Some(self.name()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an optimization is, reported to [`optimize_with_progress`]'s callback.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
@@ -89,6 +140,8 @@ impl ImageSettings {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Settings {
|
||||
/// Target PDF version (`Retain` by default).
|
||||
pub compatibility: Compatibility,
|
||||
/// Put page pictures drawn as many stacked fragments back together first ([`fragments`]).
|
||||
pub merge_fragments: bool,
|
||||
pub color: ImageSettings,
|
||||
@@ -100,16 +153,30 @@ pub struct Settings {
|
||||
/// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or
|
||||
/// ASCIIHex layer from streams that have one.
|
||||
pub flate_unencoded: bool,
|
||||
/// Clean Up: pack non-stream objects into compressed object streams with a cross-reference
|
||||
/// stream (PDF 1.5+, §7.5.7–7.5.8). Always off when `compatibility` is [`Compatibility::Pdf1_4`].
|
||||
pub object_streams: bool,
|
||||
/// Clean Up: decode LZW-compressed streams and store them with Flate instead.
|
||||
pub lzw_to_flate: bool,
|
||||
/// Clean Up: remove links (and bookmark destinations) that point nowhere.
|
||||
pub remove_invalid_links: bool,
|
||||
/// Clean Up: remove named destinations nothing refers to.
|
||||
pub remove_unreferenced_dests: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Whether `write_full` should pack non-stream objects into compressed object streams:
|
||||
/// enabled when `object_streams` is set and the target version is not PDF 1.4 (§7.5.7).
|
||||
pub fn effective_object_streams(&self) -> bool {
|
||||
self.object_streams && self.compatibility != Compatibility::Pdf1_4
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
/// Acrobat's Reduce File Size choices.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
compatibility: Compatibility::Retain,
|
||||
merge_fragments: true,
|
||||
color: ImageSettings::REDUCE,
|
||||
gray: ImageSettings::REDUCE,
|
||||
@@ -118,6 +185,8 @@ impl Default for Settings {
|
||||
discard_tags: false,
|
||||
discard_print_settings: false,
|
||||
flate_unencoded: true,
|
||||
object_streams: true,
|
||||
lzw_to_flate: true,
|
||||
remove_invalid_links: true,
|
||||
remove_unreferenced_dests: true,
|
||||
}
|
||||
@@ -178,6 +247,8 @@ pub struct Report {
|
||||
pub tags_removed: bool,
|
||||
pub print_settings: usize,
|
||||
pub streams_compressed: usize,
|
||||
/// Streams whose LZW compression was replaced with Flate (Clean Up).
|
||||
pub lzw_streams: usize,
|
||||
/// Links removed and bookmarks whose destination was cleared (Clean Up).
|
||||
pub invalid_links: usize,
|
||||
pub invalid_bookmarks: usize,
|
||||
@@ -186,6 +257,20 @@ pub struct Report {
|
||||
pub unused_xobjects: usize,
|
||||
}
|
||||
|
||||
/// The minimum PDF version required by `doc`'s active security handler, when it uses features
|
||||
/// introduced after PDF 1.4 (crypt filters in 1.5, AES-128 in 1.6, AES-256 in 1.7).
|
||||
fn required_encryption_version(doc: &Document) -> Option<&'static str> {
|
||||
let h = doc.output_handler()?;
|
||||
let d = h.dict();
|
||||
if d.v >= 5 || d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes256) {
|
||||
Some("1.7")
|
||||
} else if d.v == 4 {
|
||||
if d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes128) { Some("1.6") } else { Some("1.5") }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimize `doc` in place.
|
||||
pub fn optimize(doc: &mut Document, settings: &Settings) -> Result<Report, OptimizeError> {
|
||||
optimize_with_progress(doc, settings, &mut |_| true)
|
||||
@@ -195,6 +280,13 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result<Report, Optim
|
||||
/// during a long one). Returning `false` stops with [`OptimizeError::Cancelled`]; `doc` is then
|
||||
/// partly optimized and should be dropped.
|
||||
pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: &mut dyn FnMut(Stage) -> bool) -> Result<Report, OptimizeError> {
|
||||
// Lexical ordering is valid for these fixed versions with single-digit components.
|
||||
if let Some(chosen) = settings.compatibility.version()
|
||||
&& let Some(required) = required_encryption_version(doc)
|
||||
&& chosen < required
|
||||
{
|
||||
return Err(OptimizeError::IncompatibleEncryption { required, chosen });
|
||||
}
|
||||
let mut report = Report::default();
|
||||
let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?;
|
||||
if settings.merge_fragments {
|
||||
@@ -230,6 +322,16 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
|
||||
}
|
||||
}
|
||||
let root = doc.root();
|
||||
if let Some(chosen) = settings.compatibility.version() {
|
||||
doc.set_version(chosen);
|
||||
if let Some(root) = root
|
||||
&& doc.get(root).as_dict().is_some_and(|c| c.contains(b"Version"))
|
||||
{
|
||||
doc.update_dict(root, |c| {
|
||||
c.remove(b"Version");
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if settings.discard_tags
|
||||
&& let Some(root) = root
|
||||
&& doc.get(root).as_dict().is_some_and(|c| c.contains(b"StructTreeRoot"))
|
||||
@@ -279,6 +381,19 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
|
||||
if settings.remove_unreferenced_dests {
|
||||
report.unreferenced_dests = links::remove_unreferenced_dests(doc)?;
|
||||
}
|
||||
// Before the unencoded-stream pass, which would otherwise take the ASCII layer off an
|
||||
// `[/ASCII85Decode /LZWDecode]` stream first and count it twice.
|
||||
if settings.lzw_to_flate {
|
||||
for num in doc.object_numbers() {
|
||||
let r = ObjRef { num, generation: doc.generation(num) };
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED) {
|
||||
doc.set(r, Object::Stream(replacement));
|
||||
report.lzw_streams += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings.flate_unencoded {
|
||||
for num in doc.object_numbers() {
|
||||
let r = ObjRef { num, generation: doc.generation(num) };
|
||||
@@ -305,6 +420,101 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Largest decoded LZW stream converted, as for a page's content: the data and its check are
|
||||
/// held together, so a few hundred kilobytes of hostile LZW can't ask for gigabytes.
|
||||
const MAX_LZW_DECODED: usize = MAX_PAGE_DECODED;
|
||||
|
||||
/// `s` with its LZW compression replaced by Flate (Acrobat's "In streams that use LZW encoding,
|
||||
/// use Flate instead"). The filters up to the last LZW one are decoded, strictly and with its
|
||||
/// predictor; the result is Flate-encoded under the same predictor, without `EarlyChange`, and
|
||||
/// the filters after it keep their encoded bytes. A stream that doesn't decode cleanly, or
|
||||
/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes, is
|
||||
/// left as it is.
|
||||
fn lzw_to_flate(s: &Stream, max: usize) -> Option<Stream> {
|
||||
// An external file's data isn't in `raw`; cross-reference and object streams are rewritten
|
||||
// on save, so converting them would change nothing in the file.
|
||||
if s.dict.contains(b"F") || matches!(s.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")) {
|
||||
return None;
|
||||
}
|
||||
let names: Vec<&[u8]> = match s.dict.get(b"Filter")? {
|
||||
Object::Name(n) => vec![n.as_slice()],
|
||||
Object::Array(a) => a.iter().map(Object::as_name).collect::<Option<_>>()?,
|
||||
_ => return None,
|
||||
};
|
||||
// `/Crypt` was applied when the object was loaded, and dropping it would change how the
|
||||
// stream is encrypted.
|
||||
if names.contains(&&b"Crypt"[..]) {
|
||||
return None;
|
||||
}
|
||||
let lzw = names.iter().rposition(|n| matches!(*n, b"LZWDecode" | b"LZW"))?;
|
||||
// One entry per filter, each a dictionary or null; anything else (an indirect reference, an
|
||||
// array of the wrong length) would be read as defaults, so the data might not decode as
|
||||
// written.
|
||||
let parms: Vec<Option<&Dict>> = match s.dict.get(b"DecodeParms") {
|
||||
None | Some(Object::Null) => vec![None; names.len()],
|
||||
Some(Object::Dict(d)) if names.len() == 1 => vec![Some(d)],
|
||||
Some(Object::Array(a)) if a.len() == names.len() => a
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Object::Dict(d) => Some(Some(d)),
|
||||
Object::Null => Some(None),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?,
|
||||
_ => return None,
|
||||
};
|
||||
let chain = s.filters();
|
||||
let stages = chain.get(..=lzw)?;
|
||||
let (_, params) = stages.last()?;
|
||||
// The decoded stages' parameters must be direct integers: `filters` reads anything else (a
|
||||
// reference, 12.5, a name) as the default, and the dictionary carried over would then
|
||||
// describe the new data differently from how it was encoded.
|
||||
let direct = |p: &Option<&Dict>| p.is_none_or(|d| d.iter().all(|(_, v)| matches!(v, Object::Int(_))));
|
||||
if !parms.get(..=lzw)?.iter().all(direct) {
|
||||
return None;
|
||||
}
|
||||
let data = pdfcraft_filters::decode(stages, &s.raw, max).ok()?;
|
||||
let flate = Params { early_change: 1, ..params.clone() };
|
||||
let raw = pdfcraft_filters::encode(&Filter::Flate, &flate, &data).ok()?;
|
||||
// The predictor's encoder must give back exactly what its decoder read (a short last row,
|
||||
// unusual parameters), or the stream would change. The limit counts a PNG predictor's tag
|
||||
// bytes too, at most one per row of at least one byte.
|
||||
if pdfcraft_filters::decode(&[(Filter::Flate, flate)], &raw, data.len().saturating_mul(2).saturating_add(1)).ok()? != data {
|
||||
return None;
|
||||
}
|
||||
// Without a predictor the other parameters say nothing to Flate.
|
||||
let first = parms.get(lzw).copied().flatten().filter(|_| params.predictor > 1).map(|d| {
|
||||
let mut d = d.clone();
|
||||
d.remove(b"EarlyChange");
|
||||
Object::Dict(d)
|
||||
});
|
||||
let rest_names = names.get(lzw + 1..)?;
|
||||
let rest_parms = parms.get(lzw + 1..)?;
|
||||
let mut dict = s.dict.clone();
|
||||
dict.remove(b"Length");
|
||||
if rest_names.is_empty() {
|
||||
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
match first {
|
||||
Some(p) => dict.set(b"DecodeParms".to_vec(), p),
|
||||
None => {
|
||||
dict.remove(b"DecodeParms");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let filters = std::iter::once(Object::name("FlateDecode")).chain(rest_names.iter().map(|n| Object::Name(n.to_vec())));
|
||||
dict.set(b"Filter".to_vec(), Object::Array(filters.collect()));
|
||||
let parms: Vec<Object> = std::iter::once(first.unwrap_or(Object::Null))
|
||||
.chain(rest_parms.iter().map(|p| p.map_or(Object::Null, |d| Object::Dict(d.clone()))))
|
||||
.collect();
|
||||
if parms.iter().all(|p| matches!(p, Object::Null)) {
|
||||
dict.remove(b"DecodeParms");
|
||||
} else {
|
||||
dict.set(b"DecodeParms".to_vec(), Object::Array(parms));
|
||||
}
|
||||
}
|
||||
Some(Stream::from_raw(dict, raw))
|
||||
}
|
||||
|
||||
/// `s` without its outer ASCII85 or ASCIIHex layer (`[/ASCII85Decode /FlateDecode]` becomes
|
||||
/// `[/FlateDecode]`), when that is smaller. Only the outer layer is decoded: strictly, and within
|
||||
/// the stored size, which ASCII text never falls below, so the inner encoded bytes stay exact.
|
||||
@@ -335,3 +545,20 @@ fn stored_len(s: &Stream) -> Option<usize> {
|
||||
serialize(&Object::Dict(dict), &mut out);
|
||||
out.len().checked_add(s.raw.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_lzw_stream_that_decodes_past_the_limit_is_left_as_it_is() {
|
||||
let data = b"0 0 m 612 792 l S\n".repeat(64);
|
||||
let raw = pdfcraft_filters::encode(&Filter::Lzw, &Params::default(), &data).unwrap();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::name("LZWDecode"));
|
||||
let s = Stream::from_raw(dict, raw);
|
||||
assert!(lzw_to_flate(&s, data.len() - 1).is_none());
|
||||
let converted = lzw_to_flate(&s, data.len()).unwrap();
|
||||
assert_eq!(converted.decoded_strict().unwrap(), data);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
use pdfcraft_optimize::{Compression, ImageSettings, Settings, SpaceCategory, audit_space, effective_resolutions, optimize};
|
||||
use pdfcraft_filters::{Filter, Params};
|
||||
use pdfcraft_optimize::{
|
||||
Compatibility, Compression, ImageSettings, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize,
|
||||
};
|
||||
|
||||
fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option<ObjRef>) -> ObjRef {
|
||||
// A smooth gradient with a little texture (photo-like).
|
||||
@@ -74,6 +77,57 @@ fn stream(doc: &Document, r: ObjRef) -> Stream {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_resolution_uses_the_pages_physical_unit() {
|
||||
for (unit, scale) in [
|
||||
(None, 1.0),
|
||||
(Some(Object::Int(10)), 10.0),
|
||||
(Some(Object::Real(2.5)), 2.5),
|
||||
(Some(Object::Int(0)), 1.0),
|
||||
(Some(Object::Int(-10)), 1.0),
|
||||
(Some(Object::Real(0.5)), 1.0),
|
||||
(Some(Object::name("Ten")), 1.0),
|
||||
(Some(Object::Int(100_000)), pdfcraft_model::MAX_USER_UNIT),
|
||||
] {
|
||||
for indirect in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 720, 720, 1, false, None);
|
||||
let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q");
|
||||
let parent = doc.get(p).as_dict().unwrap().reference(b"Parent").unwrap();
|
||||
// UserUnit is page-local, unlike Resources and MediaBox.
|
||||
doc.update_dict(parent, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap();
|
||||
if let Some(unit) = unit.clone() {
|
||||
let unit = if indirect { Object::Ref(doc.add(unit)) } else { unit };
|
||||
doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), unit)).unwrap();
|
||||
}
|
||||
let ppi = effective_resolutions(&doc, &[p])[&img];
|
||||
assert!((ppi - 720.0 / scale).abs() < 1e-9, "unit={unit:?}, indirect={indirect}: {ppi}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimization_retains_an_image_below_threshold_on_a_scaled_page() {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 720, 720, 3, false, None);
|
||||
let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q");
|
||||
doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap();
|
||||
// 720 pixels across ten inches is 72 ppi, already below the 225 ppi threshold.
|
||||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(report.images_resampled, 0, "{report:?}");
|
||||
let image = stream(&doc, img);
|
||||
assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(720), Some(720)));
|
||||
let reopened = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let dimensions: Vec<_> = reopened
|
||||
.scan_objects()
|
||||
.filter_map(|(_, object)| match &*object {
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(dimensions, [(Some(720), Some(720))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_are_measured_where_drawn_and_downsampled() {
|
||||
let mut doc = Document::new_empty();
|
||||
@@ -925,6 +979,23 @@ fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef {
|
||||
inner
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_forms_are_measured_in_each_pages_physical_units() {
|
||||
for reverse in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 720, 720, 3, false, None);
|
||||
let outer = nested_forms(&mut doc, img, 3);
|
||||
let normal = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q");
|
||||
let scaled = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q");
|
||||
doc.update_dict(scaled, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap();
|
||||
let pages = if reverse { [scaled, normal] } else { [normal, scaled] };
|
||||
assert_eq!(effective_resolutions(&doc, &pages)[&img], 72.0, "reverse={reverse}");
|
||||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(report.images_resampled, 0, "{report:?}");
|
||||
assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(720));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeply_nested_form_is_measured_at_its_largest_use() {
|
||||
// 1200 px drawn 1 in wide (1200 ppi) and then 8 in wide (150 ppi): 150 ppi, so it isn't resampled.
|
||||
@@ -1036,6 +1107,77 @@ fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() {
|
||||
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption() {
|
||||
let mut base = Document::new_empty();
|
||||
page(&mut base, &[], "BT /F1 12 Tf 72 720 Td (Compatibility test) Tj ET");
|
||||
let root = base.root().unwrap();
|
||||
base.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.6"))).unwrap();
|
||||
|
||||
for (compat, expected) in [
|
||||
(Compatibility::Pdf1_4, "1.4"),
|
||||
(Compatibility::Pdf1_5, "1.5"),
|
||||
(Compatibility::Pdf1_6, "1.6"),
|
||||
(Compatibility::Pdf1_7, "1.7"),
|
||||
(Compatibility::Pdf2_0, "2.0"),
|
||||
] {
|
||||
let mut doc = base.clone();
|
||||
let settings = Settings { compatibility: compat, ..Settings::default() };
|
||||
optimize(&mut doc, &settings).unwrap();
|
||||
assert_eq!(doc.version(), expected);
|
||||
assert!(!doc.get(root).as_dict().unwrap().contains(b"Version"), "overriding catalog /Version is cleared");
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap();
|
||||
assert!(bytes.starts_with(format!("%PDF-{expected}\n").as_bytes()), "{expected}");
|
||||
let has_objstm = bytes.windows(7).any(|w| w == b"/ObjStm");
|
||||
assert_eq!(has_objstm, compat != Compatibility::Pdf1_4, "{compat:?}");
|
||||
let info = pdfcraft_render::inspect(Arc::new(bytes), None).unwrap();
|
||||
assert_eq!(info.pdf_version, expected);
|
||||
}
|
||||
|
||||
// Disabling object_streams on PDF 1.7 writes a classic cross-reference table.
|
||||
let mut doc = base.clone();
|
||||
let settings = Settings { compatibility: Compatibility::Pdf1_7, object_streams: false, ..Settings::default() };
|
||||
optimize(&mut doc, &settings).unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-1.7\n") && !bytes.windows(7).any(|w| w == b"/ObjStm"));
|
||||
|
||||
// Encryption that requires a newer PDF version than the chosen compatibility is refused.
|
||||
let mut aes256 = base.clone();
|
||||
aes256
|
||||
.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "u",
|
||||
owner_password: "o",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [1; 32],
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }),
|
||||
Err(OptimizeError::IncompatibleEncryption { required: "1.7", chosen: "1.6" })
|
||||
));
|
||||
optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf2_0, ..Settings::default() }).unwrap();
|
||||
assert_eq!(aes256.version(), "2.0");
|
||||
|
||||
let mut aes128 = base;
|
||||
aes128
|
||||
.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes128,
|
||||
user_password: "u",
|
||||
owner_password: "o",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [1; 32],
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_5, ..Settings::default() }),
|
||||
Err(OptimizeError::IncompatibleEncryption { required: "1.6", chosen: "1.5" })
|
||||
));
|
||||
optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_resolution_keeps_comments_across_content_pieces() {
|
||||
for ending in ["", "\n", "\r", "\r\n"] {
|
||||
@@ -1139,3 +1281,214 @@ fn named_dest_keys(doc: &Document) -> Vec<Vec<u8>> {
|
||||
let arr = doc.resolve(tree.get(b"Names").unwrap()).as_array().cloned().unwrap();
|
||||
arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect()
|
||||
}
|
||||
|
||||
/// Page content long enough for LZW codes to grow past 9 bits, where `EarlyChange` matters.
|
||||
fn lzw_ops() -> Vec<u8> {
|
||||
(0..300).flat_map(|i| format!("{} {} m {} {} l S\n", i % 97, i * 7 % 389, i * 13 % 612, i * 31 % 792).into_bytes()).collect()
|
||||
}
|
||||
|
||||
fn lzw(data: &[u8], params: &Params) -> Vec<u8> {
|
||||
pdfcraft_filters::encode(&Filter::Lzw, params, data).unwrap()
|
||||
}
|
||||
|
||||
/// `params` as a `/DecodeParms` dictionary, only the entries that differ from the defaults.
|
||||
fn decode_parms(p: &Params) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
for (k, v, default) in [
|
||||
(&b"Predictor"[..], p.predictor, 1),
|
||||
(b"Colors", p.colors, 1),
|
||||
(b"BitsPerComponent", p.bits_per_component, 8),
|
||||
(b"Columns", p.columns, 1),
|
||||
(b"EarlyChange", p.early_change, 1),
|
||||
] {
|
||||
if v != default {
|
||||
d.set(k.to_vec(), Object::Int(v));
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// A 64 × 48 RGB image, LZW-encoded with `params`.
|
||||
fn lzw_image(doc: &mut Document, params: &Params) -> (ObjRef, Vec<u8>) {
|
||||
let (w, h) = (64u32, 48u32);
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 5) as u8, ((i % w + i / w) * 2) as u8]).collect();
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
|
||||
d.set(b"Filter".to_vec(), Object::name("LZWDecode"));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(params)));
|
||||
(doc.add(Object::Stream(Stream::from_raw(d, lzw(&px, params)))), px)
|
||||
}
|
||||
|
||||
/// Settings that leave images to the clean-up: no resampling, no recompression.
|
||||
fn clean_up_only() -> Settings {
|
||||
let keep = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE };
|
||||
Settings { color: keep, gray: keep, ..Settings::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lzw_streams_become_flate_and_decode_to_the_same_bytes() {
|
||||
let ops = lzw_ops();
|
||||
let early = Params::default();
|
||||
let late = Params { early_change: 0, ..Params::default() };
|
||||
let png = Params { predictor: 15, colors: 3, columns: 64, ..Params::default() };
|
||||
let png_late = Params { early_change: 0, ..png.clone() };
|
||||
let mut doc = Document::new_empty();
|
||||
let content = |doc: &mut Document, p: &Params| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("LZWDecode"));
|
||||
if *p != Params::default() {
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(p)));
|
||||
}
|
||||
doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, p))))
|
||||
};
|
||||
let c_early = content(&mut doc, &early);
|
||||
let c_late = content(&mut doc, &late);
|
||||
let (img, px) = lzw_image(&mut doc, &png);
|
||||
let (img_late, _) = lzw_image(&mut doc, &png_late);
|
||||
page(&mut doc, &[("Im", img), ("Im2", img_late)], "q 64 0 0 48 72 600 cm /Im Do Q q 64 0 0 48 172 600 cm /Im2 Do Q");
|
||||
let pg = pdfcraft_annot::page_refs(&doc).unwrap()[0];
|
||||
doc.update_dict(pg, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(c_early), Object::Ref(c_late), d.get(b"Contents").unwrap().clone()]))
|
||||
})
|
||||
.unwrap();
|
||||
// Each LZW stream reads as its own data before, so a decoder ignoring EarlyChange fails here.
|
||||
assert_eq!((stream(&doc, c_early).decoded_strict().unwrap(), stream(&doc, c_late).decoded_strict().unwrap()), (ops.clone(), ops.clone()));
|
||||
assert!(lzw(&ops, &early) != lzw(&ops, &late), "the two code widths differ on this data");
|
||||
|
||||
let r = optimize(&mut doc, &clean_up_only()).unwrap();
|
||||
assert_eq!(r.lzw_streams, 4, "{r:?}");
|
||||
for (label, at, data) in
|
||||
[("content", c_early, &ops), ("content, EarlyChange 0", c_late, &ops), ("image", img, &px), ("image, EarlyChange 0", img_late, &px)]
|
||||
{
|
||||
let after = stream(&doc, at);
|
||||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode")), "{label}");
|
||||
assert_eq!(&after.decoded_strict().unwrap(), data, "{label}");
|
||||
let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict);
|
||||
assert!(parms.is_none_or(|p| !p.contains(b"EarlyChange")), "{label}: EarlyChange is LZW's alone");
|
||||
}
|
||||
// The image keeps its PNG predictor, now applied under Flate.
|
||||
let parms = stream(&doc, img).dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap();
|
||||
assert_eq!((parms.int(b"Predictor"), parms.int(b"Colors"), parms.int(b"Columns")), (Some(15), Some(3), Some(64)));
|
||||
assert!(!stream(&doc, c_late).dict.contains(b"DecodeParms"), "nothing left to say for the content stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_drawn_from_lzw_streams_renders_the_same_after_conversion() {
|
||||
let png = Params { predictor: 12, colors: 3, columns: 64, early_change: 0, ..Params::default() };
|
||||
let mut doc = Document::new_empty();
|
||||
let (img, _) = lzw_image(&mut doc, &png);
|
||||
let pg = page(&mut doc, &[("Im", img)], "");
|
||||
let ops = b"0.2 0.5 0.8 rg 36 36 300 200 re f q 256 0 0 192 72 400 cm /Im Do Q\n".repeat(4);
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("LZWDecode"));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(&Params { early_change: 0, ..Params::default() })));
|
||||
let c = doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, &Params { early_change: 0, ..Params::default() }))));
|
||||
doc.update_dict(pg, |p| p.set(b"Contents".to_vec(), Object::Ref(c))).unwrap();
|
||||
let render = |doc: &Document| {
|
||||
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
out.rgba
|
||||
};
|
||||
let before = render(&doc);
|
||||
// The picture and the rectangle are drawn: the page is not blank.
|
||||
assert!(before.chunks(4).any(|p| p[..3] != [255, 255, 255]));
|
||||
let r = optimize(&mut doc, &clean_up_only()).unwrap();
|
||||
assert_eq!(r.lzw_streams, 2, "{r:?}");
|
||||
assert_eq!(stream(&doc, c).dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
|
||||
assert!(render(&doc) == before, "the page renders pixel for pixel as before");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lzw_inside_a_filter_chain_is_replaced_and_the_other_stages_kept() {
|
||||
let ops = lzw_ops();
|
||||
let inner = lzw(&ops, &Params::default());
|
||||
// An ASCII85 layer outside the LZW goes with it; a JPEG inside it stays encoded as it was.
|
||||
let (mut doc, c) = with_content(filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&inner)));
|
||||
let photo = image(&mut doc, 40, 30, 3, true, None);
|
||||
let jpeg = stream(&doc, photo).raw.to_vec();
|
||||
let mut picture = filtered(&["LZWDecode", "DCTDecode"], lzw(&jpeg, &Params::default()));
|
||||
picture.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Null]));
|
||||
let pic = doc.add(Object::Stream(picture));
|
||||
// An ASCII85 layer over LZW with a PNG predictor: the predictor's parameters move to the one
|
||||
// Flate filter left, without EarlyChange.
|
||||
let rows: Vec<u8> = (0..40 * 30).map(|i| (i % 40 * 3 + i / 40) as u8).collect();
|
||||
let png = Params { predictor: 12, columns: 40, early_change: 0, ..Params::default() };
|
||||
let mut predicted = filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&lzw(&rows, &png)));
|
||||
predicted.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(decode_parms(&png))]));
|
||||
let pred = doc.add(Object::Stream(predicted));
|
||||
let r = optimize(&mut doc, &clean_up_only()).unwrap();
|
||||
assert_eq!(r.lzw_streams, 3, "{r:?}");
|
||||
let after = stream(&doc, pred);
|
||||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode")));
|
||||
let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap();
|
||||
assert_eq!((parms.int(b"Predictor"), parms.int(b"Columns"), parms.contains(b"EarlyChange")), (Some(12), Some(40), false));
|
||||
assert_eq!(after.decoded_strict().unwrap(), rows);
|
||||
let after = stream(&doc, c);
|
||||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode")));
|
||||
assert_eq!(after.decoded_strict().unwrap(), ops);
|
||||
let after = stream(&doc, pic);
|
||||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::Array(vec![Object::name("FlateDecode"), Object::name("DCTDecode")])));
|
||||
assert!(!after.dict.contains(b"DecodeParms"), "all null: dropped");
|
||||
let inside = pdfcraft_filters::decode(&[(Filter::Flate, Params::default())], &after.raw, 1 << 20).unwrap();
|
||||
assert_eq!(inside, jpeg, "the JPEG bytes are kept exactly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() {
|
||||
let ops = lzw_ops();
|
||||
let good = lzw(&ops, &Params::default());
|
||||
// 9-bit codes 511 after a literal: past the table, corrupt.
|
||||
let corrupt = vec![0x80, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
|
||||
assert!(filtered(&["LZWDecode"], corrupt.clone()).decoded_strict().is_err());
|
||||
let mut bad_predictor = filtered(&["LZWDecode"], good.clone());
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"Predictor".to_vec(), Object::Int(12));
|
||||
parms.set(b"Columns".to_vec(), Object::Int(7));
|
||||
bad_predictor.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||||
let mut indirect_parms = filtered(&["LZWDecode"], good.clone());
|
||||
indirect_parms.dict.set(b"DecodeParms".to_vec(), Object::Ref(ObjRef { num: 999, generation: 0 }));
|
||||
// Columns the reader can't take as an integer: read as the default 1 here, so the data is
|
||||
// predicted with one column and decodes, but a viewer resolving the value would use another.
|
||||
let one_column = Params { predictor: 12, ..Params::default() };
|
||||
let odd_columns = |columns: Object| {
|
||||
let mut s = filtered(&["LZWDecode"], lzw(&ops, &one_column));
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"Predictor".to_vec(), Object::Int(12));
|
||||
parms.set(b"Columns".to_vec(), columns);
|
||||
s.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||||
s
|
||||
};
|
||||
assert_eq!(odd_columns(Object::Real(12.5)).decoded_strict().unwrap(), ops, "read with one column");
|
||||
let mut objstm = filtered(&["LZWDecode"], good.clone());
|
||||
objstm.dict.set(b"Type".to_vec(), Object::name("ObjStm"));
|
||||
let mut external = filtered(&["LZWDecode"], good.clone());
|
||||
external.dict.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"data.bin".to_vec())));
|
||||
let cases = [
|
||||
("corrupt LZW", filtered(&["LZWDecode"], corrupt), Settings::default()),
|
||||
("corrupt under a PNG predictor", bad_predictor, Settings::default()),
|
||||
("indirect DecodeParms", indirect_parms, Settings::default()),
|
||||
("external file", external, Settings::default()),
|
||||
("object stream, rewritten on save", objstm, Settings::default()),
|
||||
("Columns by reference", odd_columns(Object::Ref(ObjRef { num: 9, generation: 0 })), Settings::default()),
|
||||
("Columns not an integer", odd_columns(Object::Real(12.5)), Settings::default()),
|
||||
("crypt filter", filtered(&["Crypt", "LZWDecode"], good.clone()), Settings::default()),
|
||||
("option off", filtered(&["LZWDecode"], good.clone()), Settings { lzw_to_flate: false, ..Settings::default() }),
|
||||
];
|
||||
for (label, s, settings) in cases {
|
||||
let (mut doc, c) = with_content(s.clone());
|
||||
let r = optimize(&mut doc, &settings).unwrap();
|
||||
assert_eq!(r.lzw_streams, 0, "{label}");
|
||||
assert_eq!(stream(&doc, c), s, "{label}");
|
||||
}
|
||||
// The same data, readable and asked for, is converted: the cases above fail for their reason.
|
||||
let (mut doc, c) = with_content(filtered(&["LZWDecode"], good));
|
||||
assert_eq!(optimize(&mut doc, &Settings::default()).unwrap().lzw_streams, 1);
|
||||
assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops);
|
||||
}
|
||||
+39
-1
@@ -719,19 +719,57 @@ pub fn keep_printed(doc: &mut Document, content: Content) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard annotation types of ISO 32000-2 Table 171.
|
||||
const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [
|
||||
b"Text",
|
||||
b"Link",
|
||||
b"FreeText",
|
||||
b"Line",
|
||||
b"Square",
|
||||
b"Circle",
|
||||
b"Polygon",
|
||||
b"PolyLine",
|
||||
b"Highlight",
|
||||
b"Underline",
|
||||
b"Squiggly",
|
||||
b"StrikeOut",
|
||||
b"Caret",
|
||||
b"Stamp",
|
||||
b"Ink",
|
||||
b"Popup",
|
||||
b"FileAttachment",
|
||||
b"Sound",
|
||||
b"Movie",
|
||||
b"Screen",
|
||||
b"Widget",
|
||||
b"PrinterMark",
|
||||
b"TrapNet",
|
||||
b"Watermark",
|
||||
b"3D",
|
||||
b"Redact",
|
||||
b"Projection",
|
||||
b"RichMedia",
|
||||
];
|
||||
|
||||
/// Does annotation `d` print under `content`?
|
||||
fn prints(doc: &Document, d: &Dict, content: Content) -> bool {
|
||||
const INVISIBLE: i64 = 1;
|
||||
const HIDDEN: i64 = 2;
|
||||
const PRINT: i64 = 4;
|
||||
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
if flags & PRINT == 0 || flags & HIDDEN != 0 {
|
||||
return false;
|
||||
}
|
||||
let subtype = d.name(b"Subtype").unwrap_or(b"");
|
||||
// `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first.
|
||||
let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s));
|
||||
let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b"");
|
||||
match subtype {
|
||||
b"Widget" => true,
|
||||
b"Link" | b"Popup" => false,
|
||||
b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly,
|
||||
// ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table
|
||||
// 171) is not printed even with its Print flag set.
|
||||
_ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false,
|
||||
_ => content == Content::DocumentAndMarkups,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,3 +961,31 @@ fn odd_and_even_pages_are_page_numbers_not_places_in_the_range() {
|
||||
// A range with no page of the kind prints nothing.
|
||||
assert_eq!(select_pages(12, Some("3, 5"), &[], Subset::Even, false), Err(PrintError::NoPages));
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard does not print,
|
||||
/// even with its Print flag; Invisible means nothing for a standard type.
|
||||
#[test]
|
||||
fn invisible_annotations_of_unknown_types_do_not_print() {
|
||||
let doc = fixture(1);
|
||||
let annot = |subtype: Option<&str>, flags: i64| {
|
||||
let mut d = Dict::new();
|
||||
if let Some(s) = subtype {
|
||||
d.set(b"Subtype".to_vec(), Object::name(s));
|
||||
}
|
||||
d.set(b"F".to_vec(), Object::Int(flags));
|
||||
d
|
||||
};
|
||||
let all = Content::DocumentAndMarkups;
|
||||
assert!(prints(&doc, &annot(Some("MyCustomType"), 4), all), "an unknown type prints without Invisible");
|
||||
assert!(!prints(&doc, &annot(Some("MyCustomType"), 5), all), "Invisible + Print on an unknown type");
|
||||
assert!(!prints(&doc, &annot(None, 5), all), "no Subtype is no standard type");
|
||||
assert!(prints(&doc, &annot(Some("Square"), 5), all), "Invisible doesn't apply to a standard type");
|
||||
assert!(prints(&doc, &annot(Some("Widget"), 5), Content::FormFieldsOnly), "nor to a field");
|
||||
|
||||
// An indirect `/Subtype` (§7.3.10) is resolved: a standard type stays standard.
|
||||
let mut doc = doc;
|
||||
let square = doc.add(Object::name("Square"));
|
||||
let mut indirect = annot(None, 5);
|
||||
indirect.set(b"Subtype".to_vec(), Object::Ref(square));
|
||||
assert!(prints(&doc, &indirect, all), "an indirect standard Subtype");
|
||||
}
|
||||
@@ -220,6 +220,9 @@ pub struct RenderedPage {
|
||||
pub enum RenderWarning {
|
||||
/// Content was skipped after the per-page decoded-content budget was exhausted.
|
||||
ContentTruncated,
|
||||
/// A colour could not be computed (a colour space that cannot be read, or a Separation or
|
||||
/// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour.
|
||||
ColourFallback,
|
||||
}
|
||||
|
||||
/// Counters for work performed by a renderer or render pool.
|
||||
@@ -402,12 +405,15 @@ fn warning_settings(settings: &InterpreterSettings, warnings: Arc<Mutex<Vec<Rend
|
||||
InterpreterSettings {
|
||||
warning_sink: Arc::new(move |warning| {
|
||||
// Nested forms, Type 3 glyphs and patterns are interpreted again and each reports the
|
||||
// exhausted budget: once per page is enough.
|
||||
if matches!(warning, InterpreterWarning::ContentTruncated) {
|
||||
let mut warnings = lock(&target);
|
||||
if !warnings.contains(&RenderWarning::ContentTruncated) {
|
||||
warnings.push(RenderWarning::ContentTruncated);
|
||||
}
|
||||
// exhausted budget, or the colours that fell back: once per page is enough.
|
||||
let warning = match warning {
|
||||
InterpreterWarning::ContentTruncated => RenderWarning::ContentTruncated,
|
||||
InterpreterWarning::ColorFallback => RenderWarning::ColourFallback,
|
||||
InterpreterWarning::UnsupportedFont | InterpreterWarning::ImageDecodeFailure => return,
|
||||
};
|
||||
let mut warnings = lock(&target);
|
||||
if !warnings.contains(&warning) {
|
||||
warnings.push(warning);
|
||||
}
|
||||
}),
|
||||
..settings.clone()
|
||||
@@ -3343,6 +3349,40 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(px(&hidden, 20, 80), vec![255, 0, 0, 255], "fields stay");
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 167: Invisible hides an annotation of a type that isn't standard and
|
||||
/// nothing else; §12.5.6.14: a popup has no appearance of its own, so its `/AP` isn't painted.
|
||||
#[test]
|
||||
fn invisible_unknown_annotations_and_popups_paint_nothing() {
|
||||
let annots = [
|
||||
"/Subtype /MyCustomType /F 0",
|
||||
"/Subtype /MyCustomType /F 1",
|
||||
"/Subtype /MyCustomType /F 5",
|
||||
"/F 1",
|
||||
"/Subtype /Square /F 1",
|
||||
"/Subtype /Popup /F 4 /Open true",
|
||||
];
|
||||
let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
|
||||
pdf.push_str("2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
|
||||
let refs: Vec<String> = (0..annots.len()).map(|i| format!("{} 0 R", 10 + i)).collect();
|
||||
pdf.push_str(&format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 50] /Annots [{}] >> endobj\n", refs.join(" ")));
|
||||
pdf.push_str("4 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream\n0 1 0 rg 0 0 30 30 re f\nendstream endobj\n");
|
||||
for (i, a) in annots.iter().enumerate() {
|
||||
let x = 10 + 50 * i;
|
||||
pdf.push_str(&format!("{} 0 obj << /Type /Annot {a} /Rect [{x} 10 {} 40] /AP << /N 4 0 R >> >> endobj\n", 10 + i, x + 30));
|
||||
}
|
||||
pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF");
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
let px = |x: u32| p.rgba[((25 * p.width + x) * 4) as usize..][..4].to_vec();
|
||||
let (green, white) = (vec![0, 255, 0, 255], vec![255, 255, 255, 255]);
|
||||
assert_eq!(px(25), green, "an unknown type without Invisible is drawn");
|
||||
assert_eq!(px(75), white, "Invisible hides an unknown type");
|
||||
assert_eq!(px(125), white, "even with the Print flag");
|
||||
assert_eq!(px(175), white, "no Subtype is no standard type");
|
||||
assert_eq!(px(225), green, "Invisible means nothing for a standard type");
|
||||
assert_eq!(px(275), white, "a popup's appearance is not painted on the page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_pool_renders_in_priority_order() {
|
||||
let pool = RenderPool::new_inline(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
|
||||
@@ -7439,6 +7479,9 @@ endstream endobj
|
||||
// ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48).
|
||||
mod icc_profile_cache;
|
||||
|
||||
// A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156).
|
||||
mod symbolic_truetype_cmap;
|
||||
|
||||
/// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own.
|
||||
fn render_first_page(pdf: Vec<u8>) -> RenderedPage {
|
||||
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
@@ -8163,6 +8206,10 @@ trailer << /Root 1 0 R >>
|
||||
// #1201: /DefaultGray, /DefaultRGB and /DefaultCMYK (vendored hayro-interpret patch 38).
|
||||
mod default_colour_spaces;
|
||||
|
||||
// #4260: DeviceN colours with many inks, and tint transforms that cannot be evaluated
|
||||
// (vendored hayro-syntax patch 28, hayro-interpret patch 55).
|
||||
mod devicen_inks;
|
||||
|
||||
/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting
|
||||
/// but draws no pixels itself.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! #4260: a DeviceN colour with more inks than the content parser held operands for, and a
|
||||
//! colour whose tint transform cannot be evaluated (ISO 32000-2 §8.6.6.5).
|
||||
//!
|
||||
//! The content parser kept at most ten operands per operator (vendored hayro-syntax patch 28):
|
||||
//! the eleventh operand of a twelve-ink `scn` ended the whole content stream, so the page came
|
||||
//! out blank with no error. A tint transform that cannot be evaluated painted the alternate
|
||||
//! space's initial colour without a word (vendored hayro-interpret patch 55): it now says so.
|
||||
|
||||
use super::{RenderWarning, rgb_at};
|
||||
use crate::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The page is 200 × 200 pt, drawn at 72 dpi.
|
||||
const SIDE: usize = 200;
|
||||
|
||||
const GREEN: [u8; 3] = [0, 255, 0];
|
||||
const RED: [u8; 3] = [255, 0, 0];
|
||||
const BLUE: [u8; 3] = [0, 0, 255];
|
||||
const BLACK: [u8; 3] = [0, 0, 0];
|
||||
|
||||
/// The twelve inks of the report's print dieline.
|
||||
const TWELVE_INKS: &str =
|
||||
"[/Cyan /Magenta /Yellow /Black /PANTONE156C /PANTONE2227C /PANTONE1565C /PANTONE2975C /PANTONE7463C /Varnish /Dieline /SGKBlack]";
|
||||
|
||||
/// The report's tint transform: all inks at 1.0 give CMYK 1 1 1 1.
|
||||
const TINT: &str = "<< /FunctionType 2 /Domain [0 1] /N 1 /C0 [0 0 0 0] /C1 [1 1 1 1] >>";
|
||||
|
||||
/// Render the one page `content` draws with the colour space `CS1` defined as `cs1`.
|
||||
fn render(content: &str, cs1: &str) -> RenderedPage {
|
||||
render_with(content, cs1, "")
|
||||
}
|
||||
|
||||
/// [`render`], with `objects` (whole indirect objects from 5 0 obj on) added to the file.
|
||||
fn render_with(content: &str, cs1: &str, objects: &str) -> RenderedPage {
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /CS1 {cs1} >> >> /Contents 4 0 R >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
{objects}trailer << /Root 1 0 R >>\n%%EOF",
|
||||
content.len()
|
||||
);
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
assert_eq!((page.width, page.height), (SIDE as u32, SIDE as u32));
|
||||
page
|
||||
}
|
||||
|
||||
/// The colour at (`x`, `y`) in page points from the bottom left.
|
||||
fn at(page: &RenderedPage, x: usize, y: usize) -> [u8; 3] {
|
||||
rgb_at(&page.rgba, SIDE, x, SIDE - 1 - y)
|
||||
}
|
||||
|
||||
/// CMYK 1 1 1 1 through the renderer's CMYK profile: very dark, whatever the exact profile.
|
||||
fn is_rich_black(rgb: [u8; 3]) -> bool {
|
||||
rgb.iter().all(|c| *c < 48)
|
||||
}
|
||||
|
||||
/// The report's page: twelve inks, all at 1.0, fill the page black. A green square drawn
|
||||
/// afterwards shows the rest of the content is still read.
|
||||
#[test]
|
||||
fn a_twelve_ink_devicen_colour_paints() {
|
||||
let content = "/CS1 cs 1 1 1 1 1 1 1 1 1 1 1 1 scn 0 0 200 200 re f 0 1 0 rg 0 0 20 20 re f";
|
||||
let page = render(content, &format!("[/DeviceN {TWELVE_INKS} /DeviceCMYK {TINT}]"));
|
||||
assert!(is_rich_black(at(&page, 100, 100)), "the twelve-ink fill paints: {:?}", at(&page, 100, 100));
|
||||
assert_eq!(at(&page, 10, 10), GREEN, "what follows the twelve-ink colour is drawn");
|
||||
assert!(page.warnings.is_empty(), "{:?}", page.warnings);
|
||||
}
|
||||
|
||||
/// The control from the report: the same colour with four inks always painted.
|
||||
#[test]
|
||||
fn a_four_ink_devicen_colour_paints() {
|
||||
let content = "/CS1 cs 1 1 1 1 scn 0 0 200 200 re f 0 1 0 rg 0 0 20 20 re f";
|
||||
let page = render(content, &format!("[/DeviceN [/Cyan /Magenta /Yellow /Black] /DeviceCMYK {TINT}]"));
|
||||
assert!(is_rich_black(at(&page, 100, 100)), "the four-ink fill paints: {:?}", at(&page, 100, 100));
|
||||
assert_eq!(at(&page, 10, 10), GREEN);
|
||||
assert!(page.warnings.is_empty(), "{:?}", page.warnings);
|
||||
}
|
||||
|
||||
/// An operator with more operands than the parser keeps still reads the ones nearest to it,
|
||||
/// as one with a few stray operands does, and the content after it is drawn.
|
||||
#[test]
|
||||
fn an_operator_with_hundreds_of_operands_does_not_end_the_content() {
|
||||
let strays = "0 ".repeat(300);
|
||||
let content = format!("1 0 0 rg 0 0 200 200 re f {strays}0 1 0 rg 0 0 20 20 re f");
|
||||
let page = render(&content, "/DeviceRGB");
|
||||
assert_eq!(at(&page, 100, 100), RED, "the content before the stray operands is drawn");
|
||||
assert_eq!(at(&page, 10, 10), GREEN, "the operator after them reads its own three operands");
|
||||
}
|
||||
|
||||
/// A tint transform that fails when it runs (a calculator function that pops more than it is
|
||||
/// given) paints that one colour in the alternate space's initial colour (black in DeviceRGB)
|
||||
/// and records a warning; the paints before and after it are drawn as usual.
|
||||
#[test]
|
||||
fn a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns() {
|
||||
let program = "{ pop pop pop }";
|
||||
let function = format!(
|
||||
"5 0 obj << /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length {} >> stream\n{program}\nendstream endobj\n",
|
||||
program.len()
|
||||
);
|
||||
let content = "1 0 0 rg 0 0 100 200 re f /CS1 cs 0.5 0.5 scn 100 0 100 200 re f 0 0 1 rg 0 0 50 50 re f";
|
||||
let page = render_with(content, "[/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R]", &function);
|
||||
assert_eq!(at(&page, 75, 150), RED, "the paint before the colour is drawn");
|
||||
assert_eq!(at(&page, 150, 150), BLACK, "the colour falls back to the alternate space's initial colour");
|
||||
assert_eq!(at(&page, 25, 25), BLUE, "the paint after the colour is drawn");
|
||||
assert!(page.warnings.contains(&RenderWarning::ColourFallback), "{:?}", page.warnings);
|
||||
}
|
||||
|
||||
/// A tint transform that cannot even be read (an unknown function type) keeps the colour space,
|
||||
/// so its paints fall back the same way, with the same warning, instead of being drawn in
|
||||
/// DeviceGray from the first tint (white here).
|
||||
#[test]
|
||||
fn a_tint_transform_that_cannot_be_read_falls_back_and_warns() {
|
||||
let cs1 = format!("[/DeviceN {TWELVE_INKS} /DeviceCMYK << /FunctionType 7 /Domain [0 1] >>]");
|
||||
let content = "/CS1 cs 1 1 1 1 1 1 1 1 1 1 1 1 scn 0 0 200 200 re f 0 1 0 rg 0 0 20 20 re f";
|
||||
let page = render(content, &cs1);
|
||||
assert!(is_rich_black(at(&page, 100, 100)), "the colour falls back to CMYK black: {:?}", at(&page, 100, 100));
|
||||
assert_eq!(at(&page, 10, 10), GREEN, "what follows is drawn");
|
||||
assert!(page.warnings.contains(&RenderWarning::ColourFallback), "{:?}", page.warnings);
|
||||
// Reported once per page, however many paints fell back.
|
||||
assert_eq!(page.warnings.iter().filter(|w| **w == RenderWarning::ColourFallback).count(), 1);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! A symbolic TrueType font draws through its (3, 0) `cmap` subtable before its (1, 0) one
|
||||
//! (vendored hayro-interpret patch, #4156; ISO 32000-2 §9.6.5.4: "If the font contains a (3, 0)
|
||||
//! subtable" it is used, "Otherwise, if the font contains a (1, 0) subtable"). SAS writes AMT
|
||||
//! fonts whose (1, 0) table is wrong, so "8" drew as "p" and "+" as "V".
|
||||
//!
|
||||
//! The font is built here, in memory: three glyphs (`.notdef`, an empty one and a filled square),
|
||||
//! a (1, 0) subtable mapping `A` to the empty glyph and a (3, 0) subtable mapping it (as U+F041)
|
||||
//! to the square. No font file is involved.
|
||||
|
||||
use super::render_first_page;
|
||||
|
||||
fn be16(out: &mut Vec<u8>, n: u16) {
|
||||
out.extend_from_slice(&n.to_be_bytes());
|
||||
}
|
||||
|
||||
fn be32(out: &mut Vec<u8>, n: u32) {
|
||||
out.extend_from_slice(&n.to_be_bytes());
|
||||
}
|
||||
|
||||
/// A TrueType font whose (1, 0) and (3, 0) subtables disagree about the code `A`.
|
||||
fn disagreeing_font() -> Vec<u8> {
|
||||
// Glyph 2: one contour, the square 0..1000 on both axes (glyph 0 and 1 are empty).
|
||||
let mut square = Vec::new();
|
||||
for n in [1i16, 0, 0, 1000, 1000] {
|
||||
out16(&mut square, n);
|
||||
}
|
||||
be16(&mut square, 3); // the contour's last point
|
||||
be16(&mut square, 0); // no instructions
|
||||
square.extend([1u8; 4]); // four on-curve points, two-byte coordinates
|
||||
for dx in [0i16, 1000, 0, -1000] {
|
||||
out16(&mut square, dx);
|
||||
}
|
||||
for dy in [0i16, 0, 1000, 0] {
|
||||
out16(&mut square, dy);
|
||||
}
|
||||
let glyf = square.clone();
|
||||
let mut loca = Vec::new();
|
||||
for offset in [0u16, 0, 0, (glyf.len() / 2) as u16] {
|
||||
be16(&mut loca, offset);
|
||||
}
|
||||
|
||||
let mut head = vec![0; 54];
|
||||
head[0..4].copy_from_slice(&[0, 1, 0, 0]);
|
||||
head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes());
|
||||
head[18..20].copy_from_slice(&1000u16.to_be_bytes());
|
||||
head[40..42].copy_from_slice(&1000i16.to_be_bytes());
|
||||
head[42..44].copy_from_slice(&1000i16.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(&1000i16.to_be_bytes());
|
||||
hhea[34..36].copy_from_slice(&3u16.to_be_bytes());
|
||||
let mut hmtx = Vec::new();
|
||||
for _ in 0..3 {
|
||||
be16(&mut hmtx, 1000);
|
||||
be16(&mut hmtx, 0);
|
||||
}
|
||||
let mut maxp = vec![0, 1, 0, 0];
|
||||
be16(&mut maxp, 3);
|
||||
maxp.resize(32, 0);
|
||||
|
||||
// (1, 0), format 0: `A` → glyph 1 (empty), and `B`, which the (3, 0) table leaves out, →
|
||||
// glyph 2.
|
||||
let mut mac = Vec::new();
|
||||
be16(&mut mac, 0);
|
||||
be16(&mut mac, 262);
|
||||
be16(&mut mac, 0);
|
||||
let mut ids = [0u8; 256];
|
||||
ids[usize::from(b'A')] = 1;
|
||||
ids[usize::from(b'B')] = 2;
|
||||
mac.extend(ids);
|
||||
// (3, 0), format 4: U+F041 → glyph 2 (the square).
|
||||
let mut win = Vec::new();
|
||||
be16(&mut win, 4);
|
||||
be16(&mut win, 32);
|
||||
be16(&mut win, 0);
|
||||
for n in [4u16, 4, 1, 0] {
|
||||
be16(&mut win, n); // segCountX2, searchRange, entrySelector, rangeShift
|
||||
}
|
||||
for n in [0xF041u16, 0xFFFF, 0, 0xF041, 0xFFFF, 2u16.wrapping_sub(0xF041), 1, 0, 0] {
|
||||
be16(&mut win, n); // endCode ×2, pad, startCode ×2, idDelta ×2, idRangeOffset ×2
|
||||
}
|
||||
let mut cmap = Vec::new();
|
||||
be16(&mut cmap, 0);
|
||||
be16(&mut cmap, 2);
|
||||
for (platform, encoding, offset) in [(1u16, 0u16, 20u32), (3, 0, 20 + mac.len() as u32)] {
|
||||
be16(&mut cmap, platform);
|
||||
be16(&mut cmap, encoding);
|
||||
be32(&mut cmap, offset);
|
||||
}
|
||||
cmap.extend(mac);
|
||||
cmap.extend(win);
|
||||
|
||||
let tables = [(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)];
|
||||
let mut out = vec![0, 1, 0, 0];
|
||||
be16(&mut out, tables.len() as u16);
|
||||
for n in [64u16, 2, 48] {
|
||||
be16(&mut out, n);
|
||||
}
|
||||
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();
|
||||
padded.resize(bytes.len().div_ceil(4) * 4, 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
|
||||
}
|
||||
|
||||
fn out16(out: &mut Vec<u8>, n: i16) {
|
||||
out.extend_from_slice(&n.to_be_bytes());
|
||||
}
|
||||
|
||||
/// A 40 × 40 pt page that shows `text` at 30 pt from (5, 5) in the font, marked symbolic and
|
||||
/// with no `/Encoding`, as SAS writes it.
|
||||
fn page_with_symbolic_font(font: &[u8], text: &str) -> Vec<u8> {
|
||||
let content = format!("BT /F1 30 Tf 5 5 Td ({text}) Tj ET").into_bytes();
|
||||
let objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(),
|
||||
[format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(),
|
||||
b"<< /Type /Font /Subtype /TrueType /BaseFont /AAAAAA+Sym /FirstChar 65 /LastChar 66 /Widths [1000 1000] /FontDescriptor 6 0 R >>".to_vec(),
|
||||
b"<< /Type /FontDescriptor /FontName /AAAAAA+Sym /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(),
|
||||
[format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes());
|
||||
pdf.extend(object);
|
||||
pdf.extend(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes());
|
||||
for offset in offsets {
|
||||
pdf.extend(format!("{offset:010} 00000 n \n").into_bytes());
|
||||
}
|
||||
pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbolic_truetype_font_draws_through_its_3_0_subtable_first() {
|
||||
let font = disagreeing_font();
|
||||
// The square covers (5, 5)–(35, 35) in page space: row 20 from the top is inside it.
|
||||
let square_drawn = |text: &str| {
|
||||
let page = render_first_page(page_with_symbolic_font(&font, text));
|
||||
let at = |x: u32, y: u32| page.rgba.get(((y * page.width + x) * 4) as usize).copied().unwrap_or(255);
|
||||
assert_eq!(at(2, 2), 255, "nothing is drawn outside the square");
|
||||
at(20, 20) < 128
|
||||
};
|
||||
assert!(square_drawn("A"), "the (3, 0) subtable's square, not the (1, 0) subtable's empty glyph");
|
||||
assert!(square_drawn("B"), "a code the (3, 0) subtable leaves out still reads through (1, 0)");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! The request a font resolver gets for a CIDFont the document doesn't embed carries the style
|
||||
//! of the descendant's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1, §9.8). Contributor-original
|
||||
//! synthetic fixtures.
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_interpret::font::{FallbackFontQuery, FontQuery};
|
||||
use hayro::hayro_interpret::hayro_cmap::CidFamily;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::{RenderCache, RenderSettings, render};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A one-page document drawing two characters in a Type 0 font whose descendant is a
|
||||
/// non-embedded CIDFontType0 with the given entries after `/CIDSystemInfo`. The name says
|
||||
/// nothing about the style and matches no standard font.
|
||||
fn fixture(descendant_extra: &str, descriptor: Option<&str>) -> Vec<u8> {
|
||||
let mut objects = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
stream("", "BT /F1 24 Tf 10 40 Td <11b30ed4> Tj ET"),
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /CraftCJK-W3 /Encoding /Identity-H /DescendantFonts [6 0 R] >>".into(),
|
||||
format!(
|
||||
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /CraftCJK-W3 \
|
||||
/CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> {descendant_extra} >>"
|
||||
),
|
||||
];
|
||||
if let Some(descriptor) = descriptor {
|
||||
objects.push(descriptor.to_string());
|
||||
}
|
||||
pdf(&objects)
|
||||
}
|
||||
|
||||
/// The fallback queries the resolver received while drawing the page.
|
||||
fn fallback_queries(bytes: Vec<u8>) -> Vec<FallbackFontQuery> {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = seen.clone();
|
||||
let settings = InterpreterSettings {
|
||||
font_resolver: Arc::new(move |query| {
|
||||
if let FontQuery::Fallback(f) = query {
|
||||
sink.lock().unwrap().push(f.clone());
|
||||
}
|
||||
None
|
||||
}),
|
||||
..InterpreterSettings::default()
|
||||
};
|
||||
let pdf = Pdf::new(bytes).unwrap();
|
||||
let pages = pdf.pages();
|
||||
let page = pages.first().unwrap();
|
||||
let cache = RenderCache::new();
|
||||
render(page, &cache, &settings, &RenderSettings::default());
|
||||
seen.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cid_font_fallback_request_carries_its_descendants_style() {
|
||||
// Flags: Serif (bit 2), Nonsymbolic (bit 6), Italic (bit 7) = 2 + 32 + 64.
|
||||
let descriptor = "<< /Type /FontDescriptor /FontName /CraftCJK-W3 /Flags 98 /FontWeight 700 \
|
||||
/FontBBox [0 -120 1000 880] /ItalicAngle -12 /Ascent 880 /Descent -120 /CapHeight 700 /StemV 120 >>";
|
||||
let queries = fallback_queries(fixture("/FontDescriptor 7 0 R", Some(descriptor)));
|
||||
let query = queries.first().expect("the resolver is asked for a fallback");
|
||||
assert_eq!(query.post_script_name.as_deref(), Some("CraftCJK-W3"));
|
||||
assert!(query.character_collection.as_ref().is_some_and(|cc| cc.family == CidFamily::AdobeGB1), "{query:?}");
|
||||
assert!(query.is_serif, "Serif flag of the descendant's descriptor: {query:?}");
|
||||
assert!(query.is_italic, "Italic flag of the descendant's descriptor: {query:?}");
|
||||
assert_eq!(query.font_weight, 700, "/FontWeight of the descendant's descriptor: {query:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cid_font_without_a_descriptor_asks_for_the_default_style() {
|
||||
let queries = fallback_queries(fixture("", None));
|
||||
let query = queries.first().expect("the resolver is asked for a fallback");
|
||||
assert_eq!(query.post_script_name.as_deref(), Some("CraftCJK-W3"));
|
||||
assert!(!query.is_serif && !query.is_italic && !query.is_bold, "{query:?}");
|
||||
assert_eq!(query.font_weight, 400);
|
||||
}
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn stream(dict: &str, body: &str) -> String {
|
||||
format!("<< {dict} /Length {} >>\nstream\n{body}\nendstream", body.len())
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod cid_fallback_query;
|
||||
mod inline_image_ei;
|
||||
mod jbig2_invariants;
|
||||
mod named_form_properties;
|
||||
mod search;
|
||||
mod substitute_widths;
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Contributor-original synthetic fonts: a simple font the document names but doesn't embed is
|
||||
//! drawn with a standard-font stand-in, its glyphs placed at the advances `/Widths` gives
|
||||
//! (ISO 32000-2 §9.6.2.1, Table 109) and their shapes kept.
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest};
|
||||
use std::sync::Arc;
|
||||
|
||||
const SIZE: f64 = 80.0;
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// A page showing `text` at 80 pt in the font dictionary `font`, with the font descriptor
|
||||
/// `descriptor` as object 6.
|
||||
fn page(font: &str, descriptor: &str, text: &str) -> Vec<u8> {
|
||||
let body = format!("BT /F1 {SIZE} Tf 20 30 Td ({text}) Tj ET");
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 140] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||||
font.into(),
|
||||
descriptor.into(),
|
||||
])
|
||||
}
|
||||
|
||||
/// The runs of columns holding dark pixels, as (first, last) column.
|
||||
fn ink_runs(bytes: Vec<u8>) -> Vec<(u32, u32)> {
|
||||
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(image.error.is_none(), "{:?}", image.error);
|
||||
let dark = |x: u32| {
|
||||
(0..image.height).any(|y| {
|
||||
let at = usize::try_from((y * image.width + x) * 4).unwrap_or(usize::MAX);
|
||||
image.rgba.get(at).is_some_and(|r| *r < 128)
|
||||
})
|
||||
};
|
||||
let mut runs: Vec<(u32, u32)> = Vec::new();
|
||||
for x in 0..image.width {
|
||||
if dark(x) {
|
||||
match runs.last_mut() {
|
||||
Some(run) if run.1 + 1 == x => run.1 = x,
|
||||
_ => runs.push((x, x)),
|
||||
}
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
fn standard(name: &str, text: &str) -> Vec<(u32, u32)> {
|
||||
ink_runs(page(&format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name} /Encoding /WinAnsiEncoding >>"), "<< >>", text))
|
||||
}
|
||||
|
||||
fn substituted(base_font: &str, flags: u32, weight: u32, code: u8, width: u32, text: &str) -> Vec<(u32, u32)> {
|
||||
let font = format!(
|
||||
"<< /Type /Font /Subtype /TrueType /BaseFont /{base_font} /Encoding /WinAnsiEncoding \
|
||||
/FirstChar {code} /LastChar {code} /Widths [{width}] /FontDescriptor 6 0 R >>"
|
||||
);
|
||||
let descriptor = format!(
|
||||
"<< /Type /FontDescriptor /FontName /{base_font} /Flags {flags} /FontBBox [-600 -200 2000 1000] \
|
||||
/ItalicAngle 0 /Ascent 900 /Descent -200 /CapHeight 700 /StemV 140 /FontWeight {weight} >>"
|
||||
);
|
||||
ink_runs(page(&font, &descriptor, text))
|
||||
}
|
||||
|
||||
/// A `/Widths` entry wider than the stand-in's glyph moves the next glyph on, and the glyph keeps
|
||||
/// its own shape: every advance of 600 stretched each glyph of `Arial,Bold` to 600 units, so the
|
||||
/// stems of `i` and `l` became solid blocks twice their width and the text read as a heavy
|
||||
/// fixed-pitch face.
|
||||
#[test]
|
||||
fn a_substitute_keeps_its_glyph_shapes_at_wider_advances() {
|
||||
for (base_font, flags, weight, stand_in) in [
|
||||
("Arial,Bold", 32, 700, "Helvetica-Bold"),
|
||||
("Arial,Bold", 4, 700, "Helvetica-Bold"),
|
||||
("Arial-BoldMT", 32, 700, "Helvetica-Bold"),
|
||||
("ArialMT", 32, 400, "Helvetica"),
|
||||
] {
|
||||
let reference = standard(stand_in, "l");
|
||||
let [(ref_start, ref_end)] = reference[..] else { panic!("{stand_in}: one stem, {reference:?}") };
|
||||
let runs = substituted(base_font, flags, weight, b'l', 600, "ll");
|
||||
let [(first, first_end), (second, second_end)] = runs[..] else { panic!("{base_font}: two stems, {runs:?}") };
|
||||
for (start, end) in [(first, first_end), (second, second_end)] {
|
||||
assert!(
|
||||
(end - start).abs_diff(ref_end - ref_start) <= 1,
|
||||
"{base_font} /Flags {flags}: the stem is {stand_in}'s, not stretched: {runs:?} vs {reference:?}"
|
||||
);
|
||||
}
|
||||
// 600 units at 80 pt is 48 pixels.
|
||||
assert!((second - first).abs_diff(48) <= 1, "{base_font}: the second l is 600 units on: {runs:?}");
|
||||
assert!(first.abs_diff(ref_start) <= 1, "{base_font}: the first l starts where {stand_in}'s does: {runs:?} vs {reference:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/Widths` entry narrower than the stand-in's glyph still narrows the glyph to it, so
|
||||
/// neighbours don't overlap: `W` (944 units in Helvetica-Bold) at an advance of 400.
|
||||
#[test]
|
||||
fn a_substitute_still_narrows_glyphs_to_narrower_advances() {
|
||||
let runs = substituted("Arial,Bold", 32, 700, b'W', 400, "WW");
|
||||
let [(first, first_end), (second, _)] = runs[..] else { panic!("two separate glyphs: {runs:?}") };
|
||||
// 400 units at 80 pt is 32 pixels.
|
||||
assert!(first_end - first <= 33, "the W is narrowed to its advance: {runs:?}");
|
||||
assert!((second - first).abs_diff(32) <= 1, "the second W is 400 units on: {runs:?}");
|
||||
}
|
||||
@@ -929,10 +929,15 @@ impl PdfCraftApp {
|
||||
/// Close tab `index` for a close request, then ask about the next dirty document, or quit.
|
||||
fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) {
|
||||
self.close_tab(index);
|
||||
// A newer prompt wins: the user may have started another close while a save was pending.
|
||||
// A newer prompt wins: the user may have started another close while a save was pending,
|
||||
// and seen its question, which keeps counting as seen.
|
||||
if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() {
|
||||
match self.first_dirty() {
|
||||
Some(_) => self.close_request = Some(req),
|
||||
Some(_) => {
|
||||
// A question about the next document is a new one.
|
||||
self.save_prompt_seen = false;
|
||||
self.close_request = Some(req);
|
||||
}
|
||||
None if req == CloseRequest::Quit => self.quit(ctx),
|
||||
None => {}
|
||||
}
|
||||
@@ -989,6 +994,32 @@ impl PdfCraftApp {
|
||||
self.shutdown_recovery();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for the user's attention when the "save changes?" question opens while PdfCraft is in
|
||||
/// the background (Quit in the Dock menu, logging out, or an agent quitting), and withdraw the
|
||||
/// request once the user comes back or the question is answered.
|
||||
///
|
||||
/// The question holds up the quit the user asked for, so the request is critical, as AppKit
|
||||
/// makes it for a modal panel that an inactive app presents: the Dock icon bounces until
|
||||
/// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and
|
||||
/// X11 sets the urgency hint, which stays until it is reset. A question that opens in front
|
||||
/// asks nothing, and neither does leaving it open: the user has seen it. Each document asked
|
||||
/// about when quitting is a new question, so one that opens in the background asks even after
|
||||
/// a question the user saw; a request that still stands carries over to it.
|
||||
pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) {
|
||||
let open = self.close_request.is_some();
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
if self.save_prompt_attention {
|
||||
if focused || !open {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset));
|
||||
self.save_prompt_attention = false;
|
||||
}
|
||||
} else if open && !self.save_prompt_seen && !focused {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical));
|
||||
self.save_prompt_attention = true;
|
||||
}
|
||||
self.save_prompt_seen = open;
|
||||
}
|
||||
}
|
||||
|
||||
/// The single page a content edit changes (`None` for other edits). Like comment edits,
|
||||
@@ -1734,3 +1765,45 @@ mod content_page_tests {
|
||||
assert_eq!(content_page(&Edit::SetInfo { key: "Title".into(), value: "x".into() }), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod attention_tests {
|
||||
use super::CloseRequest;
|
||||
use crate::PdfCraftApp;
|
||||
|
||||
/// The attention requests one frame of `attend_save_prompt` sends, with the window `focused`
|
||||
/// or not.
|
||||
fn attend(app: &mut PdfCraftApp, ctx: &egui::Context, focused: bool) -> Vec<egui::UserAttentionType> {
|
||||
let mut out = ctx.run_ui(egui::RawInput { focused, ..Default::default() }, |ui| app.attend_save_prompt(ui.ctx()));
|
||||
// The frame's font texture isn't uploaded anywhere in a unit test.
|
||||
out.textures_delta.clear();
|
||||
out.viewport_output
|
||||
.values()
|
||||
.flat_map(|v| v.commands.iter())
|
||||
.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A save that finishes after a newer "save changes?" question opened in front, and after the
|
||||
/// user went to another app, asks for no attention: the user saw that question.
|
||||
#[test]
|
||||
fn a_save_finishing_behind_a_newer_question_the_user_saw_asks_for_nothing() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PdfCraftApp::new();
|
||||
for name in ["a.pdf", "b.pdf", "c.pdf"] {
|
||||
let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
||||
app.open_bytes(name, None, bytes.as_ref().clone()).unwrap();
|
||||
app.active = Some(app.views.len() - 1);
|
||||
assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }));
|
||||
}
|
||||
// a.pdf was answered with Save while quitting. While that save is pending, the user closes
|
||||
// c.pdf's tab, and its question opens in front.
|
||||
let c = app.views[2].id;
|
||||
app.close_request = Some(CloseRequest::Tab(c));
|
||||
assert!(attend(&mut app, &ctx, true).is_empty());
|
||||
// a.pdf's save finishes once the user has gone to another app.
|
||||
app.close_and_continue(&ctx, 0, CloseRequest::Quit);
|
||||
assert_eq!(app.close_request, Some(CloseRequest::Tab(c)), "the newer question stands");
|
||||
assert!(attend(&mut app, &ctx, false).is_empty(), "the user has seen it");
|
||||
}
|
||||
}
|
||||
@@ -611,6 +611,7 @@
|
||||
Discard private data of other applications تجاهل البيانات الخاصة بالتطبيقات الأخرى
|
||||
Discard hidden text تجاهل النص المخفي
|
||||
Use Flate to encode streams that are not encoded استخدام Flate لترميز التدفقات غير المرمَّزة
|
||||
In streams that use LZW encoding, use Flate instead في التدفقات التي تستخدم ترميز LZW، استخدام Flate بدلاً منه
|
||||
Remove invalid links and bookmarks إزالة الروابط والإشارات المرجعية غير الصالحة
|
||||
Remove unreferenced named destinations إزالة الوجهات المسماة غير المشار إليها
|
||||
Compress document structure (object streams) ضغط بنية المستند (تدفقات الكائنات)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -282,6 +282,7 @@
|
||||
Discard private data of other applications Премахни частните данни на други приложения
|
||||
Discard hidden text Премахни скрития текст
|
||||
Use Flate to encode streams that are not encoded Кодирай с Flate некодираните потоци
|
||||
In streams that use LZW encoding, use Flate instead Кодирай с Flate потоците, кодирани с LZW
|
||||
Remove invalid links and bookmarks Премахни невалидните връзки и отметки
|
||||
Remove unreferenced named destinations Премахни неизползваните именувани местоназначения
|
||||
Compress document structure (object streams) Компресирай структурата на документа (потоци от обекти)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -574,6 +574,7 @@
|
||||
Discard private data of other applications Descarta les dades privades d'altres aplicacions
|
||||
Discard hidden text Descarta el text ocult
|
||||
Use Flate to encode streams that are not encoded Utilitza Flate per codificar els fluxos que no estan codificats
|
||||
In streams that use LZW encoding, use Flate instead Als fluxos que utilitzen codificació LZW, utilitza Flate
|
||||
Remove invalid links and bookmarks Elimina els enllaços i marcadors no vàlids
|
||||
Remove unreferenced named destinations Elimina les destinacions amb nom sense referència
|
||||
Compress document structure (object streams) Comprimeix l'estructura del document (fluxos d'objectes)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 3.
|
@@ -581,6 +581,7 @@
|
||||
Discard private data of other applications فڕێدانی داتا تایبەتەکانی بەرنامەکانی تر
|
||||
Discard hidden text فڕێدانی دەقی شاراوە
|
||||
Use Flate to encode streams that are not encoded بەکارهێنانی Flate بۆ کۆدکردنی ئەو لێشاوانەی کۆد نەکراون
|
||||
In streams that use LZW encoding, use Flate instead بەکارهێنانی Flate لە جیاتی LZW بۆ ئەو لێشاوانەی بە LZW کۆد کراون
|
||||
Remove invalid links and bookmarks لابردنی بەستەر و دڵخوازە نادروستەکان
|
||||
Remove unreferenced named destinations لابردنی مەبەستە ناونراوەکانی ئاماژەپێنەکراو
|
||||
Compress document structure (object streams) پەستاندنی پێکهاتەی بەڵگەنامە (لێشاوەکانی تەن)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -692,6 +692,7 @@ key Space Mezerník
|
||||
Discard private data of other applications Zahodit soukromá data jiných aplikací
|
||||
Discard hidden text Zahodit skrytý text
|
||||
Use Flate to encode streams that are not encoded Kódovat nekódované proudy metodou Flate
|
||||
In streams that use LZW encoding, use Flate instead Proudy kódované LZW kódovat metodou Flate
|
||||
Remove invalid links and bookmarks Odebrat neplatné odkazy a záložky
|
||||
Remove unreferenced named destinations Odebrat nepoužívané pojmenované cíle
|
||||
Compress document structure (object streams) Komprimovat strukturu dokumentu (proudy objektů)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -584,6 +584,7 @@
|
||||
Discard private data of other applications Private Daten anderer Anwendungen verwerfen
|
||||
Discard hidden text Ausgeblendeten Text verwerfen
|
||||
Use Flate to encode streams that are not encoded Nicht codierte Streams mit Flate codieren
|
||||
In streams that use LZW encoding, use Flate instead In LZW-codierten Streams stattdessen Flate verwenden
|
||||
Remove invalid links and bookmarks Ungültige Links und Lesezeichen entfernen
|
||||
Remove unreferenced named destinations Nicht referenzierte benannte Ziele entfernen
|
||||
Compress document structure (object streams) Dokumentstruktur komprimieren (Objekt-Streams)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -1923,6 +1923,7 @@
|
||||
Discard private data of other applications Αφαίρεση ιδιωτικών δεδομένων άλλων εφαρμογών
|
||||
Discard hidden text Αφαίρεση κρυφού κειμένου
|
||||
Use Flate to encode streams that are not encoded Χρήση συμπίεσης Flate για ροές που δεν είναι συμπιεσμένες
|
||||
In streams that use LZW encoding, use Flate instead Χρήση Flate αντί για LZW στις ροές με κωδικοποίηση LZW
|
||||
Remove invalid links and bookmarks Αφαίρεση μη έγκυρων συνδέσμων και σελιδοδεικτών
|
||||
Remove unreferenced named destinations Αφαίρεση ονομασμένων προορισμών χωρίς αναφορές
|
||||
Compress document structure (object streams) Συμπίεση δομής εγγράφου (ροές αντικειμένων)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 482 and column 9.
|
@@ -607,6 +607,7 @@
|
||||
Discard private data of other applications Descartar datos privados de otras aplicaciones
|
||||
Discard hidden text Descartar texto oculto
|
||||
Use Flate to encode streams that are not encoded Usar Flate para codificar las secuencias sin codificar
|
||||
In streams that use LZW encoding, use Flate instead En las secuencias con codificación LZW, usar Flate en su lugar
|
||||
Remove invalid links and bookmarks Quitar enlaces y marcadores no válidos
|
||||
Remove unreferenced named destinations Quitar destinos con nombre sin referencias
|
||||
Compress document structure (object streams) Comprimir la estructura del documento (secuencias de objetos)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -574,6 +574,7 @@
|
||||
Discard private data of other applications Hylkää muiden sovellusten yksityiset tiedot
|
||||
Discard hidden text Hylkää piilotettu teksti
|
||||
Use Flate to encode streams that are not encoded Koodaa koodaamattomat virrat Flate-menetelmällä
|
||||
In streams that use LZW encoding, use Flate instead Koodaa LZW-koodatut virrat Flate-menetelmällä
|
||||
Remove invalid links and bookmarks Poista virheelliset linkit ja kirjanmerkit
|
||||
Remove unreferenced named destinations Poista viittaamattomat nimetyt kohteet
|
||||
Compress document structure (object streams) Pakkaa asiakirjan rakenne (objektivirrat)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 47.
|
@@ -584,6 +584,7 @@
|
||||
Discard private data of other applications Ignorer les données privées d'autres applications
|
||||
Discard hidden text Ignorer le texte masqué
|
||||
Use Flate to encode streams that are not encoded Utiliser Flate pour coder les flux qui ne sont pas codés
|
||||
In streams that use LZW encoding, use Flate instead Dans les flux qui utilisent le codage LZW, utiliser Flate à la place
|
||||
Remove invalid links and bookmarks Retirer les liens et signets non valides
|
||||
Remove unreferenced named destinations Retirer les destinations nommées non référencées
|
||||
Compress document structure (object streams) Compresser la structure du document (flux d'objets)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -558,6 +558,7 @@
|
||||
Discard private data of other applications अन्य ऐप्लिकेशन का निजी डेटा हटाएँ
|
||||
Discard hidden text छिपा हुआ टेक्स्ट हटाएँ
|
||||
Use Flate to encode streams that are not encoded जो स्ट्रीम एन्कोडेड नहीं हैं उन्हें Flate से एन्कोड करें
|
||||
In streams that use LZW encoding, use Flate instead LZW एन्कोडिंग वाली स्ट्रीम में इसके बजाय Flate का उपयोग करें
|
||||
Remove invalid links and bookmarks अमान्य लिंक और बुकमार्क हटाएँ
|
||||
Remove unreferenced named destinations बिना संदर्भ वाले नामित गंतव्य हटाएँ
|
||||
Compress document structure (object streams) दस्तावेज़ संरचना संपीड़ित करें (ऑब्जेक्ट स्ट्रीम)
|
||||
|
||||
|
@@ -582,6 +582,7 @@
|
||||
Discard private data of other applications Más alkalmazások privát adatainak elvetése
|
||||
Discard hidden text Rejtett szöveg elvetése
|
||||
Use Flate to encode streams that are not encoded A kódolatlan adatfolyamok Flate kódolása
|
||||
In streams that use LZW encoding, use Flate instead Az LZW kódolású adatfolyamok Flate kódolása
|
||||
Remove invalid links and bookmarks Érvénytelen hivatkozások és könyvjelzők eltávolítása
|
||||
Remove unreferenced named destinations Hivatkozás nélküli elnevezett célok eltávolítása
|
||||
Compress document structure (object streams) Dokumentumszerkezet tömörítése (objektumfolyamok)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -1219,6 +1219,7 @@
|
||||
Update Perbarui
|
||||
Use 72 DPI (one point per pixel) Gunakan 72 DPI (satu titik per piksel)
|
||||
Use Flate to encode streams that are not encoded Gunakan Flate untuk mengodekan stream yang belum dikodekan
|
||||
In streams that use LZW encoding, use Flate instead Pada stream yang memakai pengodean LZW, gunakan Flate sebagai gantinya
|
||||
Use a Digital ID from a file Gunakan ID Digital dari file
|
||||
Use custom DPI Gunakan DPI kustom
|
||||
Use each image's embedded DPI; use 72 DPI when it is absent. Gunakan DPI tertanam pada setiap gambar; gunakan 72 DPI jika tidak ada.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 483 and column 12.
|
@@ -608,6 +608,7 @@
|
||||
Discard private data of other applications Elimina dati privati di altre applicazioni
|
||||
Discard hidden text Elimina testo nascosto
|
||||
Use Flate to encode streams that are not encoded Usa Flate per codificare i flussi non codificati
|
||||
In streams that use LZW encoding, use Flate instead Nei flussi con codifica LZW, usa Flate
|
||||
Remove invalid links and bookmarks Rimuovi collegamenti e segnalibri non validi
|
||||
Remove unreferenced named destinations Rimuovi destinazioni con nome non referenziate
|
||||
Compress document structure (object streams) Comprimi struttura del documento (flussi di oggetti)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -611,6 +611,7 @@
|
||||
Discard private data of other applications 他のアプリケーションのプライベートデータを破棄
|
||||
Discard hidden text 非表示テキストを破棄
|
||||
Use Flate to encode streams that are not encoded 未エンコードのストリームを Flate でエンコード
|
||||
In streams that use LZW encoding, use Flate instead LZW エンコードのストリームを Flate でエンコード
|
||||
Remove invalid links and bookmarks 無効なリンクとしおりを削除
|
||||
Remove unreferenced named destinations 参照されていない名前付き移動先を削除
|
||||
Compress document structure (object streams) 文書構造を圧縮(オブジェクトストリーム)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -571,6 +571,7 @@
|
||||
Discard private data of other applications Басқа қосымшалардың жеке деректерін алып тастау
|
||||
Discard hidden text Жасырын мәтінді алып тастау
|
||||
Use Flate to encode streams that are not encoded Кодталмаған ағындарды Flate көмегімен кодтау
|
||||
In streams that use LZW encoding, use Flate instead LZW кодталған ағындарда оның орнына Flate қолдану
|
||||
Remove invalid links and bookmarks Жарамсыз сілтемелерді және бетбелгілерді алып тастау
|
||||
Remove unreferenced named destinations Сілтеме берілмеген атаулы межелерді алып тастау
|
||||
Compress document structure (object streams) Құжат құрылымын сығу (нысан ағындары)
|
||||
|
||||
|
@@ -558,6 +558,7 @@
|
||||
Discard private data of other applications 다른 응용 프로그램의 개인 데이터 삭제
|
||||
Discard hidden text 숨겨진 텍스트 삭제
|
||||
Use Flate to encode streams that are not encoded 인코딩되지 않은 스트림을 Flate로 인코딩
|
||||
In streams that use LZW encoding, use Flate instead LZW 인코딩 스트림을 Flate로 인코딩
|
||||
Remove invalid links and bookmarks 잘못된 링크 및 책갈피 제거
|
||||
Remove unreferenced named destinations 참조되지 않은 이름이 지정된 대상 제거
|
||||
Compress document structure (object streams) 문서 구조 압축(개체 스트림)
|
||||
|
||||
|
@@ -579,6 +579,7 @@
|
||||
Discard private data of other applications ຕັດຂໍ້ມູນສ່ວນຕົວຂອງແອັບພລິເຄຊັນອື່ນອອກ
|
||||
Discard hidden text ຕັດຂໍ້ຄວາມທີ່ເຊື່ອງໄວ້ອອກ
|
||||
Use Flate to encode streams that are not encoded ໃຊ້ Flate ເພື່ອເຂົ້າລະຫັດສະຕຣີມທີ່ຍັງບໍ່ໄດ້ເຂົ້າລະຫັດ
|
||||
In streams that use LZW encoding, use Flate instead ໃຊ້ Flate ແທນ LZW ໃນສະຕຣີມທີ່ເຂົ້າລະຫັດດ້ວຍ LZW
|
||||
Remove invalid links and bookmarks ລຶບລິ້ງຄ໌ ແລະ ບຸກມາກທີ່ໃຊ້ການບໍ່ໄດ້
|
||||
Remove unreferenced named destinations ລຶບຈຸດໝາຍປາຍທາງທີ່ມີຊື່ເຊິ່ງບໍ່ມີການອ້າງອີງ
|
||||
Compress document structure (object streams) ບີບອັດໂຄງສ້າງເອກະສານ (ສະຕຣີມວັດຖຸ)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 32.
|
@@ -1278,6 +1278,7 @@
|
||||
Update Atnaujinti
|
||||
Use 72 DPI (one point per pixel) Naudoti 72 DPI (vienas taškas vienam pikseliui)
|
||||
Use Flate to encode streams that are not encoded Nekoduotiems srautams koduoti naudoti „Flate“
|
||||
In streams that use LZW encoding, use Flate instead LZW koduotiems srautams naudoti „Flate“
|
||||
Use a Digital ID from a file Naudoti faile esantį skaitmeninį ID
|
||||
Use custom DPI Naudoti pasirinktinį DPI
|
||||
Use each image's embedded DPI; use 72 DPI when it is absent. Naudoti paveikslėlyje įrašytą DPI; jei jo nėra, naudoti 72 DPI.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 406 and column 9.
|
@@ -1228,6 +1228,7 @@
|
||||
Update Oppdater
|
||||
Use 72 DPI (one point per pixel) Bruk 72 DPI (ett punkt per piksel)
|
||||
Use Flate to encode streams that are not encoded Bruk Flate til å kode strømmer som ikke er kodet
|
||||
In streams that use LZW encoding, use Flate instead Bruk Flate i stedet for LZW i strømmer som er LZW-kodet
|
||||
Use a Digital ID from a file Bruk en digital ID fra en fil
|
||||
Use custom DPI Bruk egendefinert DPI
|
||||
Use each image's embedded DPI; use 72 DPI when it is absent. Bruk hvert bildes innebygde DPI; bruk 72 DPI når den mangler.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -606,6 +606,7 @@
|
||||
Discard private data of other applications Usuń prywatne dane innych aplikacji
|
||||
Discard hidden text Usuń ukryty tekst
|
||||
Use Flate to encode streams that are not encoded Zakoduj nieskompresowane strumienie metodą Flate
|
||||
In streams that use LZW encoding, use Flate instead Zakoduj strumienie LZW metodą Flate
|
||||
Remove invalid links and bookmarks Usuń nieprawidłowe łącza i zakładki
|
||||
Remove unreferenced named destinations Usuń nieużywane nazwane miejsca docelowe
|
||||
Compress document structure (object streams) Kompresuj strukturę dokumentu (strumienie obiektów)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -238,6 +238,7 @@
|
||||
Discard private data of other applications Descartar dados privados de outros aplicativos
|
||||
Discard hidden text Descartar texto oculto
|
||||
Use Flate to encode streams that are not encoded Usar Flate para codificar fluxos não codificados
|
||||
In streams that use LZW encoding, use Flate instead Em fluxos que usam codificação LZW, usar Flate
|
||||
Remove invalid links and bookmarks Remover links e marcadores inválidos
|
||||
Remove unreferenced named destinations Remover destinos nomeados não referenciados
|
||||
Compress document structure (object streams) Compactar estrutura do documento (fluxos de objetos)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1228,6 +1228,7 @@
|
||||
Update Actualizare
|
||||
Use 72 DPI (one point per pixel) Folosire 72 DPI (un punct pe pixel)
|
||||
Use Flate to encode streams that are not encoded Folosire Flate pentru codarea fluxurilor necodate
|
||||
In streams that use LZW encoding, use Flate instead Folosire Flate în locul LZW pentru fluxurile codate LZW
|
||||
Use a Digital ID from a file Folosire ID digital dintr-un fișier
|
||||
Use custom DPI Folosire DPI personalizat
|
||||
Use each image's embedded DPI; use 72 DPI when it is absent. Folosește DPI-ul încorporat al fiecărei imagini; 72 DPI când lipsește.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 110.
|
@@ -604,6 +604,7 @@
|
||||
Discard private data of other applications Удалить личные данные других приложений
|
||||
Discard hidden text Удалить скрытый текст
|
||||
Use Flate to encode streams that are not encoded Использовать Flate для кодирования некодированных потоков
|
||||
In streams that use LZW encoding, use Flate instead Использовать Flate вместо LZW в потоках с кодированием LZW
|
||||
Remove invalid links and bookmarks Удалить недействительные ссылки и закладки
|
||||
Remove unreferenced named destinations Удалить неиспользуемые именованные места назначения
|
||||
Compress document structure (object streams) Сжать структуру документа (потоки объектов)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1964,6 +1964,7 @@
|
||||
Update watermark… Posodobi vodni žig…
|
||||
Use 72 DPI (one point per pixel) Uporabi 72 DPI (ena točka na slikovno piko)
|
||||
Use Flate to encode streams that are not encoded Za kodiranje nekodiranih tokov uporabi Flate
|
||||
In streams that use LZW encoding, use Flate instead V tokovih s kodiranjem LZW uporabi Flate
|
||||
Use JavaScript Uporabi JavaScript
|
||||
Use a Digital ID from a file Uporabi digitalno identiteto iz datoteke
|
||||
Use a certificate Uporabi potrdilo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1910,6 +1910,7 @@
|
||||
Update watermark… Përditëso filigranin…
|
||||
Use 72 DPI (one point per pixel) Përdor 72 DPI (një pikë për piksel)
|
||||
Use Flate to encode streams that are not encoded Përdor Flate për të koduar rrjedhat që nuk janë të koduara
|
||||
In streams that use LZW encoding, use Flate instead Në rrjedhat që përdorin kodimin LZW, përdor Flate në vend të tij
|
||||
Use JavaScript Përdor JavaScript
|
||||
Use a Digital ID from a file Përdor një ID dixhitale nga një skedar
|
||||
Use a certificate Përdor një certifikatë
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -588,6 +588,7 @@ comment menu Edit సవరించు
|
||||
Discard private data of other applications ఇతర అప్లికేషన్ల ప్రైవేట్ డేటాను విస్మరించు
|
||||
Discard hidden text దాచిన టెక్స్ట్ను విస్మరించు
|
||||
Use Flate to encode streams that are not encoded ఎన్కోడ్ చేయని స్ట్రీమ్లను Flateతో ఎన్కోడ్ చేయి
|
||||
In streams that use LZW encoding, use Flate instead LZW ఎన్కోడింగ్ ఉన్న స్ట్రీమ్లలో బదులుగా Flate ఉపయోగించు
|
||||
Remove invalid links and bookmarks చెల్లని లింక్లు, బుక్మార్క్లను తీసివేయి
|
||||
Remove unreferenced named destinations ఎక్కడా సూచించని పేరున్న గమ్యాలను తీసివేయి
|
||||
Compress document structure (object streams) పత్రం నిర్మాణాన్ని కుదించు (ఆబ్జెక్ట్ స్ట్రీమ్లు)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -568,6 +568,7 @@
|
||||
Discard private data of other applications ละทิ้งข้อมูลเฉพาะของแอปพลิเคชันอื่น
|
||||
Discard hidden text ละทิ้งข้อความที่ซ่อนอยู่
|
||||
Use Flate to encode streams that are not encoded ใช้ Flate เข้ารหัสสตรีมที่ยังไม่ได้เข้ารหัส
|
||||
In streams that use LZW encoding, use Flate instead ใช้ Flate แทนในสตรีมที่เข้ารหัสแบบ LZW
|
||||
Remove invalid links and bookmarks เอาลิงก์และบุ๊กมาร์กที่ใช้ไม่ได้ออก
|
||||
Remove unreferenced named destinations เอาปลายทางที่มีชื่อซึ่งไม่ถูกอ้างถึงออก
|
||||
Compress document structure (object streams) บีบอัดโครงสร้างเอกสาร (object streams)
|
||||
|
||||
|
@@ -569,6 +569,7 @@
|
||||
Discard private data of other applications Diğer uygulamaların özel verilerini at
|
||||
Discard hidden text Gizli metni at
|
||||
Use Flate to encode streams that are not encoded Kodlanmamış akışları kodlamak için Flate kullan
|
||||
In streams that use LZW encoding, use Flate instead LZW kodlamalı akışlarda bunun yerine Flate kullan
|
||||
Remove invalid links and bookmarks Geçersiz bağlantıları ve yer işaretlerini kaldır
|
||||
Remove unreferenced named destinations Referans verilmeyen adlandırılmış hedefleri kaldır
|
||||
Compress document structure (object streams) Belge yapısını sıkıştır (nesne akışları)
|
||||
|
||||
|
@@ -609,6 +609,7 @@
|
||||
Discard private data of other applications Видалити приватні дані інших застосунків
|
||||
Discard hidden text Видалити прихований текст
|
||||
Use Flate to encode streams that are not encoded Кодувати незакодовані потоки за допомогою Flate
|
||||
In streams that use LZW encoding, use Flate instead Використовувати Flate замість LZW у потоках із кодуванням LZW
|
||||
Remove invalid links and bookmarks Видалити недійсні посилання й закладки
|
||||
Remove unreferenced named destinations Видалити іменовані призначення без посилань
|
||||
Compress document structure (object streams) Стиснути структуру документа (потоки об’єктів)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1268,6 +1268,7 @@ theme Appearance Giao diện
|
||||
Update Cập nhật
|
||||
Use 72 DPI (one point per pixel) Dùng 72 DPI (một điểm cho mỗi điểm ảnh)
|
||||
Use Flate to encode streams that are not encoded Dùng Flate để mã hóa các luồng chưa được mã hóa
|
||||
In streams that use LZW encoding, use Flate instead Dùng Flate thay cho LZW trong các luồng mã hóa LZW
|
||||
Use a Digital ID from a file Dùng Digital ID từ một tệp
|
||||
Use custom DPI Dùng DPI tùy chỉnh
|
||||
Use each image's embedded DPI; use 72 DPI when it is absent. Dùng DPI nhúng của từng hình ảnh; dùng 72 DPI khi không có.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 493 and column 12.
|
@@ -288,6 +288,7 @@
|
||||
Discard private data of other applications 丢弃其他应用程序的私有数据
|
||||
Discard hidden text 丢弃隐藏文本
|
||||
Use Flate to encode streams that are not encoded 使用 Flate 编码未编码的流
|
||||
In streams that use LZW encoding, use Flate instead 在使用 LZW 编码的流中改用 Flate
|
||||
Remove invalid links and bookmarks 删除无效链接和书签
|
||||
Remove unreferenced named destinations 删除未引用的命名目标
|
||||
Compress document structure (object streams) 压缩文档结构(对象流)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -244,6 +244,7 @@
|
||||
Discard private data of other applications 捨棄其他應用程式的私有資料
|
||||
Discard hidden text 捨棄隱藏文字
|
||||
Use Flate to encode streams that are not encoded 使用 Flate 編碼未編碼的流
|
||||
In streams that use LZW encoding, use Flate instead 在使用 LZW 編碼的流中改用 Flate
|
||||
Remove invalid links and bookmarks 刪除無效連結和書籤
|
||||
Remove unreferenced named destinations 刪除未引用的命名目標
|
||||
Compress document structure (object streams) 壓縮文件結構(物件流)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -642,6 +642,12 @@ pub struct PdfCraftApp {
|
||||
pub close_request: Option<CloseRequest>,
|
||||
/// The button of the "save changes?" question the keyboard has chosen; Save when it opens.
|
||||
pub(crate) save_prompt_button: dialogs::PromptButton,
|
||||
/// `attend_save_prompt` has seen the pending "save changes?" question. It is false again once
|
||||
/// the question is answered, so the next document's question is told apart from it.
|
||||
save_prompt_seen: bool,
|
||||
/// The "save changes?" question asked for the user's attention and the request still stands
|
||||
/// (see `attend_save_prompt`).
|
||||
save_prompt_attention: bool,
|
||||
/// A save waiting behind [`Dialog::OverwriteChanged`] (the web build has no files to check).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub pending_overwrite: Option<editing::PendingOverwrite>,
|
||||
@@ -1018,6 +1024,8 @@ impl PdfCraftApp {
|
||||
host_dirty: None,
|
||||
close_request: None,
|
||||
save_prompt_button: Default::default(),
|
||||
save_prompt_seen: false,
|
||||
save_prompt_attention: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pending_overwrite: None,
|
||||
save_override: None,
|
||||
@@ -3147,6 +3155,8 @@ impl eframe::App for PdfCraftApp {
|
||||
storage.set_string("pdfcraft", self.persist());
|
||||
}
|
||||
|
||||
/// Each frame's work before drawing: opens, operating-system and control-channel requests,
|
||||
/// the quit guard, shortcuts, and the background jobs' results.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.ctx = Some(ctx.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -3313,6 +3323,8 @@ impl eframe::App for PdfCraftApp {
|
||||
self.process_file_requests();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.process_picked();
|
||||
// Last, after everything that can open or answer the "save changes?" question.
|
||||
self.attend_save_prompt(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Align, Layout};
|
||||
use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset, QUALITIES, Settings};
|
||||
use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset, QUALITIES, Settings};
|
||||
use pdfcraft_engine::optimizer::{OptimizeStage, Optimized};
|
||||
use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport};
|
||||
|
||||
@@ -172,6 +172,17 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(tl!("Compatibility"));
|
||||
let compat_label = |c: Compatibility| match c {
|
||||
Compatibility::Retain => tl!("Retain existing").to_string(),
|
||||
_ => format!("PDF {}", c.name()),
|
||||
};
|
||||
egui::ComboBox::from_id_salt("optimize-compatibility").selected_text(compat_label(d.settings.compatibility)).show_ui(ui, |ui| {
|
||||
for c in Compatibility::ALL {
|
||||
ui.selectable_value(&mut d.settings.compatibility, c, compat_label(c));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@@ -219,9 +230,19 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo
|
||||
}
|
||||
OptimizeTab::CleanUp => {
|
||||
ui.add(widgets::checkbox(&mut s.flate_unencoded, tl!("Use Flate to encode streams that are not encoded")));
|
||||
ui.add(widgets::checkbox(&mut s.lzw_to_flate, tl!("In streams that use LZW encoding, use Flate instead")));
|
||||
ui.add(widgets::checkbox(&mut s.remove_invalid_links, tl!("Remove invalid links and bookmarks")));
|
||||
ui.add(widgets::checkbox(&mut s.remove_unreferenced_dests, tl!("Remove unreferenced named destinations")));
|
||||
ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Compress document structure (object streams)")));
|
||||
let mut object_streams = s.effective_object_streams();
|
||||
if ui
|
||||
.add_enabled(
|
||||
s.compatibility != Compatibility::Pdf1_4,
|
||||
widgets::checkbox(&mut object_streams, tl!("Compress document structure (object streams)")),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
s.object_streams = object_streams;
|
||||
}
|
||||
ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Remove unused objects and merge identical ones")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +254,65 @@ fn control_quit_preserves_dirty_documents_and_cancel_keeps_them_open() {
|
||||
assert!(!close_requested(&h));
|
||||
}
|
||||
|
||||
/// Run one frame and return the attention requests it sent to the window.
|
||||
fn step_attention(h: &mut Harness<'static, PdfCraftApp>) -> Vec<egui::UserAttentionType> {
|
||||
h.step();
|
||||
let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter());
|
||||
commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// Send a request, run frames until it is answered and three more, and collect every attention
|
||||
/// request those frames sent.
|
||||
fn attention_during(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Vec<egui::UserAttentionType> {
|
||||
let rx = c.send(method, params);
|
||||
let mut sent = Vec::new();
|
||||
for _ in 0..30 {
|
||||
sent.extend(step_attention(h));
|
||||
if rx.try_recv().is_ok() {
|
||||
(0..3).for_each(|_| sent.extend(step_attention(h)));
|
||||
return sent;
|
||||
}
|
||||
}
|
||||
panic!("{method}: no reply after 30 frames");
|
||||
}
|
||||
|
||||
/// Open `name` with an unsaved change, in a tab of its own.
|
||||
fn open_unsaved(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||||
let app = h.state_mut();
|
||||
app.open_bytes(name, None, fixture(1)).unwrap();
|
||||
app.active = Some(app.views.len() - 1);
|
||||
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
}
|
||||
|
||||
/// An agent quits while another app is in front, then answers the prompts itself. Quitting asks
|
||||
/// about each unsaved document in turn; the request made for the first question stands for the
|
||||
/// next, and answering the last withdraws it, so the taskbar or the window list stops flagging a
|
||||
/// window with nothing to ask. Each question is a new one: when the first opened in front, the
|
||||
/// next one asks when it opens in the background.
|
||||
#[test]
|
||||
fn a_save_prompt_answered_in_the_background_withdraws_its_attention_request() {
|
||||
use egui::UserAttentionType::{Critical, Reset};
|
||||
let (mut h, c) = harness();
|
||||
ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"}));
|
||||
open_unsaved(&mut h, "second.pdf");
|
||||
h.input_mut().focused = false;
|
||||
assert_eq!(attention_during(&mut h, &c, "ui.quit", json!({})), [Critical]);
|
||||
assert!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})).is_empty(), "the request stands");
|
||||
assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]);
|
||||
|
||||
open_unsaved(&mut h, "third.pdf");
|
||||
h.input_mut().focused = true;
|
||||
assert!(attention_during(&mut h, &c, "ui.quit", json!({})).is_empty(), "the first question opens in front");
|
||||
h.input_mut().focused = false;
|
||||
assert!(attention_during(&mut h, &c, "ui.state", json!({})).is_empty(), "leaving it open asks nothing");
|
||||
assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})), [Critical], "the next opens in the background");
|
||||
assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]);
|
||||
let state = ok(&mut h, &c, "ui.state", json!({}));
|
||||
assert_eq!(state["close_prompt"], false);
|
||||
let left: Vec<_> = state["documents"].as_array().unwrap().iter().map(|d| (d["name"].clone(), d["dirty"].clone())).collect();
|
||||
assert_eq!(left, [(json!("third.pdf"), json!(true))], "Don't save closed doc.pdf and second.pdf; Cancel kept third.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_quit_clean_document_requests_normal_window_close() {
|
||||
for params in [json!({}), Value::Null] {
|
||||
|
||||
@@ -239,6 +239,19 @@ fn the_pdf_optimizer_dialog_picks_a_preset() {
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().optimize_draft.settings.merge_fragments);
|
||||
h.get_by_value("Custom");
|
||||
|
||||
// Pick a target PDF version and toggle structure compression on Clean Up.
|
||||
h.get_by_value("Retain existing").click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("PDF 2.0").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().optimize_draft.settings.compatibility, pdfcraft_engine::optimize::Compatibility::Pdf2_0);
|
||||
h.get_by_label("Clean Up").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().optimize_draft.settings.object_streams);
|
||||
h.get_by_label("Compress document structure (object streams)").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().optimize_draft.settings.object_streams);
|
||||
}
|
||||
|
||||
fn mixed_files(app: &PdfCraftApp) -> Vec<(String, Vec<u8>)> {
|
||||
|
||||
@@ -791,6 +791,50 @@ fn quitting_with_unsaved_changes_asks_for_each_document() {
|
||||
assert!(h.state().close_request.is_none());
|
||||
}
|
||||
|
||||
/// Run `frames` frames and collect every attention request they sent to the window.
|
||||
fn attention_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> Vec<egui::UserAttentionType> {
|
||||
let mut sent = Vec::new();
|
||||
for _ in 0..frames {
|
||||
h.step();
|
||||
let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter());
|
||||
sent.extend(commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }));
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
/// Close the window as the operating system does: Quit in the Dock menu, or logging out.
|
||||
fn close_window(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
|
||||
}
|
||||
|
||||
/// Quit from the Dock while another app is in front: the prompt opens in a window the user can't
|
||||
/// see, so the Dock icon bounces (the taskbar button flashes) until they come back. A prompt that
|
||||
/// opens in front asks for nothing.
|
||||
#[test]
|
||||
fn a_save_prompt_opened_in_the_background_asks_for_attention_until_the_user_returns() {
|
||||
use egui::UserAttentionType::{Critical, Reset};
|
||||
let mut h = harness(2, |_| {});
|
||||
h.state_mut().views[0].select_pages(&[0]);
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
h.input_mut().focused = false;
|
||||
close_window(&mut h);
|
||||
assert_eq!(attention_over(&mut h, 4), [Critical], "asked once, when the prompt opened");
|
||||
assert_eq!(h.state().close_request, Some(CloseRequest::Quit));
|
||||
h.input_mut().focused = true;
|
||||
assert_eq!(attention_over(&mut h, 3), [Reset], "coming back withdraws the request");
|
||||
h.get_by_label_contains("Save changes to “doc.pdf”");
|
||||
h.get_by_label("Cancel").click();
|
||||
assert!(attention_over(&mut h, 3).is_empty());
|
||||
assert_eq!(h.state().views.len(), 1, "cancel keeps the document open");
|
||||
// With PdfCraft in front, the prompt is in plain sight.
|
||||
close_window(&mut h);
|
||||
assert!(attention_over(&mut h, 4).is_empty());
|
||||
h.get_by_label_contains("Save changes to “doc.pdf”");
|
||||
// Leaving with the prompt open doesn't ask either: the user has seen it.
|
||||
h.input_mut().focused = false;
|
||||
assert!(attention_over(&mut h, 3).is_empty());
|
||||
}
|
||||
|
||||
/// PDFs written to a fresh temp folder, removed when the test ends.
|
||||
struct TempPdfs(std::path::PathBuf);
|
||||
|
||||
|
||||
@@ -44,6 +44,15 @@ this works only when that build input actually contains the full face. This chan
|
||||
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.
|
||||
|
||||
When mixed-style text needs outline fallback, characters using the same actual fallback face
|
||||
share Type 3 fonts, with at most 240 distinct glyphs per font. Adjacent characters with the same
|
||||
font and source style are written as one run. Latin characters that the source font can encode
|
||||
stay in it; Japanese serif/sans and weight choices remain separate. More than 240 missing glyphs
|
||||
use additional fonts; a styled paragraph needing more than 15,360 distinct fallback glyphs is
|
||||
refused with an error. Unsupported glyphs are refused before fallback objects are written.
|
||||
This does not expand glyph coverage, shape mixed contextual runs or remove old unused fonts
|
||||
when editing again. The separate single-style fallback path is unchanged.
|
||||
|
||||
Rewrapped Chinese and Japanese text, which has no spaces between words, may break between any
|
||||
two characters. A row never begins with closing punctuation, a closing bracket or quote, a small
|
||||
kana or the prolonged sound mark, and never ends with an opening bracket or quote or a currency
|
||||
|
||||
+17
-3
@@ -2,6 +2,11 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
|
||||
@@ -76,6 +81,7 @@
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged)
|
||||
@@ -178,7 +184,7 @@ total matches the parity estimate.
|
||||
| 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; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>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<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| 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; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>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<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| 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; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>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<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| 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: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. 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. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>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. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. 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: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. 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. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>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. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. 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: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. 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. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>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. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. 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). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. 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; XFDF values retain text across XML comments and processing instructions.<br>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<br>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. |
|
||||
@@ -186,8 +192,8 @@ total matches the parity estimate.
|
||||
| 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), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Reading colours: pending page backgrounds follow the configured colour (#2059). |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).<br>Reading colours: pending page backgrounds follow the configured colour (#2059). |
|
||||
@@ -235,6 +241,10 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
## Revision history
|
||||
|
||||
**2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms.
|
||||
Synthetic measurement and optimization regressions cover the change.
|
||||
Estimates unchanged.
|
||||
|
||||
**2026-10-20 · minor:** M2/#3601: substitute-font spacing aliases preserve blank ink, original `/W` and `/DW` advances, and Unicode extraction. Synthetic renderer regressions cover the reported Japanese CID fixture. Estimates unchanged.
|
||||
|
||||
**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
|
||||
@@ -242,6 +252,9 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. |
|
||||
| 2026-10-23 | minor | M10: Export all images and Save Image As apply soft-mask Decode intervals to PNG alpha before resizing, including inverted/subrange, indirect, packed and JPEG masks. Estimates unchanged. |
|
||||
| 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. |
|
||||
| 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
|
||||
@@ -335,3 +348,4 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. |
|
||||
| 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones |
|
||||
| 2026-10-18 | minor | #3227: M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged. |
|
||||
@@ -586,8 +586,8 @@ milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = ["file.close"]
|
||||
tools = ["doc_close"]
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/it/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens"]
|
||||
notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. Not yet: the same keys on the other confirmations (Revert, reload, overwrite)."
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/it/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens", "crates/ui-egui/tests/it/editing.rs::a_save_prompt_opened_in_the_background_asks_for_attention_until_the_user_returns", "crates/ui-egui/tests/it/control.rs::a_save_prompt_answered_in_the_background_withdraws_its_attention_request"]
|
||||
notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. A prompt that opens while PdfCraft is in the background (Quit in the Dock menu, logging out, an agent's ui.quit) asks once for the user's attention, as a critical request: the Dock icon bounces until PdfCraft is active, the Windows taskbar button flashes until the window is in front, and X11 sets the urgency hint; coming back or answering withdraws it. Not yet: the same keys on the other confirmations (Revert, reload, overwrite); the other confirmations don't ask for attention; on macOS a prompt answered without coming back (over the control channel) leaves the Dock icon bouncing until PdfCraft is activated, because winit can't withdraw the request there."
|
||||
|
||||
[[feature]]
|
||||
id = "core.dirty-indicator"
|
||||
@@ -783,7 +783,7 @@ status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/text.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf.rs::identity_to_unicode_names_read_codes_as_unicode"]
|
||||
notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face); a `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters"
|
||||
notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face); a `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters"
|
||||
|
||||
[[feature]]
|
||||
id = "view.render-image-codecs"
|
||||
@@ -806,8 +806,8 @@ milestone = "M2"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame"]
|
||||
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet"
|
||||
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print"]
|
||||
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)"
|
||||
|
||||
[[feature]]
|
||||
id = "view.render-error-isolation"
|
||||
@@ -1866,8 +1866,8 @@ milestone = "M7"
|
||||
status = "partial"
|
||||
commands = ["edit.edit_text"]
|
||||
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/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/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/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/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/it/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/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/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/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/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/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools"]
|
||||
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); 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. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). 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"
|
||||
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/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/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/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/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/it/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/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/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/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/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/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"]
|
||||
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); 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. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.text-reflow"
|
||||
@@ -2153,8 +2153,8 @@ milestone = "M7"
|
||||
status = "shipped"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["image_save"]
|
||||
evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"]
|
||||
notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported."
|
||||
evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha"]
|
||||
notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.image-external-editor"
|
||||
@@ -7016,8 +7016,8 @@ milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["export.docx"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"]
|
||||
notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity."
|
||||
evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"]
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-rtf"
|
||||
@@ -7028,8 +7028,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["export.rtf"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"]
|
||||
notes = "Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out."
|
||||
evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"]
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-xlsx"
|
||||
@@ -7052,7 +7052,7 @@ milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["export.pptx"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = [
|
||||
evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit",
|
||||
"crates/export/src/pptx.rs::pptx_is_a_presentationml_package",
|
||||
"crates/export/src/pptx.rs::pptx_slide_has_title_body_and_images_in_order",
|
||||
"crates/export/src/pptx.rs::pptx_text_colour_and_formatting",
|
||||
@@ -7069,7 +7069,7 @@ evidence = [
|
||||
"crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf",
|
||||
"crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf",
|
||||
]
|
||||
notes = "PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead."
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-png"
|
||||
@@ -7116,8 +7116,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["export.all_images"]
|
||||
tools = ["doc_export_all_images"]
|
||||
evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"]
|
||||
notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported."
|
||||
evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"]
|
||||
notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-html"
|
||||
@@ -7128,8 +7128,8 @@ milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["export.html"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"]
|
||||
notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts."
|
||||
evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"]
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-text"
|
||||
@@ -7285,11 +7285,11 @@ title = "Make compatible with a chosen PDF version"
|
||||
area = "K"
|
||||
tier = "P1"
|
||||
milestone = "M11"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["optimize.advanced"]
|
||||
tools = ["doc_optimize"]
|
||||
evidence = ["crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset"]
|
||||
notes = "Retain existing (default), PDF 1.4, 1.5, 1.6, 1.7 or 2.0 in PDF Optimizer and doc_optimize; writes the chosen %PDF- header, clears any overriding catalog /Version, disables and unpacks object streams and cross-reference streams for PDF 1.4, and refuses when the document's encryption requires a newer PDF version (#3090). Partial compatibility only: content newer than the target is neither detected nor converted (including optional content, JPX images, 16-bit images, OpenType fonts and PDF 2.0 features); the chosen header does not guarantee compatibility with older readers"
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.optimizer"
|
||||
@@ -7312,8 +7312,8 @@ milestone = "M11"
|
||||
status = "partial"
|
||||
commands = ["optimize.advanced"]
|
||||
tools = ["doc_optimize"]
|
||||
evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured"]
|
||||
notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet"
|
||||
evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units"]
|
||||
notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet"
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.recompress-images"
|
||||
@@ -7585,11 +7585,11 @@ title = "Compress document structure (object streams)"
|
||||
area = "K"
|
||||
tier = "P1"
|
||||
milestone = "M11"
|
||||
status = "partial"
|
||||
commands = ["file.save_as"]
|
||||
tools = ["doc_save"]
|
||||
evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams"]
|
||||
notes = "on by default for full saves; no Optimizer option yet"
|
||||
status = "shipped"
|
||||
commands = ["file.save_as", "optimize.advanced"]
|
||||
tools = ["doc_save", "doc_optimize"]
|
||||
evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams", "crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset"]
|
||||
notes = "on by default for full saves and in PDF Optimizer ▸ Clean Up (and doc_optimize object_streams); turning it off or targeting PDF 1.4 unpacks object streams into standalone objects with a classic cross-reference table"
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.flate-unencoded"
|
||||
@@ -7600,8 +7600,8 @@ milestone = "M11"
|
||||
status = "partial"
|
||||
commands = ["optimize.advanced"]
|
||||
tools = ["doc_optimize"]
|
||||
evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up"]
|
||||
notes = "unencoded streams; LZW not yet re-encoded"
|
||||
evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up", "crates/optimize/tests/optimize.rs::lzw_streams_become_flate_and_decode_to_the_same_bytes", "crates/optimize/tests/optimize.rs::a_page_drawn_from_lzw_streams_renders_the_same_after_conversion"]
|
||||
notes = "Two Clean Up options, as in Acrobat: unencoded streams are Flate-compressed, and LZW streams (content and image XObjects; EarlyChange 0 and 1, with or without a predictor, LZW anywhere in a filter chain) are decoded and stored with Flate instead, keeping the predictor and the filters after the LZW one; LZW data that doesn't decode cleanly is left as it is. Not yet: LZW inline images inside content streams."
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.remove-invalid-links"
|
||||
|
||||
Vendored
+11
-2
@@ -17,11 +17,11 @@ Other resource categories retain their existing parent-chain lookup.
|
||||
|
||||
| Crate | Version | Licence | Patches | Test |
|
||||
|---|---|---|---|---|
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
|
||||
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of thLine truncated
|
||||
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) |
|
||||
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` |
|
||||
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwLine truncated
|
||||
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwLine truncated
|
||||
| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, Line truncated
|
||||
| egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure |
|
||||
| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate |
|
||||
@@ -92,3 +92,12 @@ are unchanged. Renderer regressions `japanese_substitute_missing_spaces_draw_no_
|
||||
and `japanese_substitute_spaces_keep_pdf_widths_and_unicode` use the synthetic
|
||||
MS-Mincho / UniJIS-UCS2-HW-H fixture with craft-fonts, including `/W` and `/DW`
|
||||
placement controls.
|
||||
|
||||
Symbolic TrueType `cmap` order (#4156): in `hayro-interpret` `EmbeddedKind::map_code`, an
|
||||
embedded TrueType font whose Nonsymbolic flag is not set (the branch `is_non_symbolic()` does not
|
||||
take) looks a code up in its Windows (3, 0) or (3, 1) subtables before its Macintosh and Unicode
|
||||
ones, as ISO 32000-2 §9.6.5.4 prescribes for symbolic fonts; the
|
||||
(1, 0) subtable still serves codes the (3, x) ones leave out. The records were tried in the
|
||||
order the font lists them, platform ID first, so a wrong (1, 0) table (SAS 9.4's AMT fonts)
|
||||
drew "8" as "p" and "+" as "V". Covered by `raster::tests::symbolic_truetype_cmap`, whose font
|
||||
is built in memory.
|
||||
Vendored
+54
-10
@@ -28,6 +28,22 @@ pub type ColorComponents = SmallVec<[f32; 4]>;
|
||||
std::thread_local! {
|
||||
// PdfCraft patch (34): see `tint_transform_runs`.
|
||||
static TINT_TRANSFORM_RUNS: core::cell::Cell<u64> = const { core::cell::Cell::new(0) };
|
||||
// PdfCraft patch (55): see `note_color_fallback`.
|
||||
static COLOR_FALLBACK: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// PdfCraft patch (55): record that a colour could not be computed and was painted in a
|
||||
/// fallback colour, reported as `InterpreterWarning::ColorFallback` when the content stream
|
||||
/// being interpreted ends. Per thread, like the page's other budgets: one page is interpreted
|
||||
/// on one thread.
|
||||
pub(crate) fn note_color_fallback() {
|
||||
COLOR_FALLBACK.with(|fell_back| fell_back.set(true));
|
||||
}
|
||||
|
||||
/// PdfCraft patch (55): whether a colour fell back since the last call (or since the page
|
||||
/// started), clearing it, so each fallback is reported once.
|
||||
pub(crate) fn take_color_fallback() -> bool {
|
||||
COLOR_FALLBACK.with(|fell_back| fell_back.replace(false))
|
||||
}
|
||||
|
||||
/// PdfCraft patch (34): how many colours this thread has run through a Separation or DeviceN
|
||||
@@ -1030,10 +1046,30 @@ impl ToRgb for Indexed {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Separation {
|
||||
alternate_space: ColorSpace,
|
||||
tint_transform: Function,
|
||||
/// PdfCraft patch (55): `None` when the tint transform cannot be read (see
|
||||
/// `eval_tint_transform`).
|
||||
tint_transform: Option<Function>,
|
||||
is_none_separation: bool,
|
||||
}
|
||||
|
||||
/// PdfCraft patch (55): `tint_transform` run on the tints of one colour, or, when it cannot be
|
||||
/// read or evaluated, the initial colour of `alternate_space` (black in every device space), with
|
||||
/// the fallback recorded. hayro already fell back this way when evaluation failed, silently; a
|
||||
/// transform it could not read made the whole colour space unreadable, and the space's
|
||||
/// paints were drawn in DeviceGray from their first tint instead (white for a full tint).
|
||||
fn eval_tint_transform(
|
||||
tint_transform: Option<&Function>,
|
||||
alternate_space: &ColorSpace,
|
||||
tints: &[f32],
|
||||
) -> ColorComponents {
|
||||
tint_transform
|
||||
.and_then(|f| f.eval(tints.to_smallvec()))
|
||||
.unwrap_or_else(|| {
|
||||
note_color_fallback();
|
||||
alternate_space.initial_color()
|
||||
})
|
||||
}
|
||||
|
||||
impl Separation {
|
||||
fn new(array: &Array<'_>, cache: &Cache) -> Option<Self> {
|
||||
let mut iter = array.flex_iter();
|
||||
@@ -1041,7 +1077,9 @@ impl Separation {
|
||||
let _ = iter.next::<Name<'_>>()?;
|
||||
let name = iter.next::<Name<'_>>()?;
|
||||
let alternate_space = ColorSpace::new(iter.next::<Object<'_>>()?, cache)?;
|
||||
let tint_transform = Function::new(&iter.next::<Object<'_>>()?)?;
|
||||
// PdfCraft patch (55): an unreadable tint transform keeps the space (see
|
||||
// `eval_tint_transform`).
|
||||
let tint_transform = iter.next::<Object<'_>>().and_then(|f| Function::new(&f));
|
||||
// Either I did something wrong, or no other viewers properly handles
|
||||
// `All`, so let's just ignore it as well.
|
||||
let is_none_separation = name.as_str() == "None";
|
||||
@@ -1057,12 +1095,15 @@ impl Separation {
|
||||
impl ToRgb for Separation {
|
||||
fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
|
||||
count_tint_transform_runs(input.len());
|
||||
// PdfCraft patch (55): see `eval_tint_transform`.
|
||||
let evaluated = input
|
||||
.iter()
|
||||
.flat_map(|n| {
|
||||
self.tint_transform
|
||||
.eval(smallvec![*n])
|
||||
.unwrap_or(self.alternate_space.initial_color())
|
||||
eval_tint_transform(
|
||||
self.tint_transform.as_ref(),
|
||||
&self.alternate_space,
|
||||
&[*n],
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.alternate_space.convert_f32(&evaluated, output, false)
|
||||
@@ -1077,7 +1118,9 @@ impl ToRgb for Separation {
|
||||
pub(crate) struct DeviceN {
|
||||
alternate_space: ColorSpace,
|
||||
num_components: u8,
|
||||
tint_transform: Function,
|
||||
/// PdfCraft patch (55): `None` when the tint transform cannot be read (see
|
||||
/// `eval_tint_transform`).
|
||||
tint_transform: Option<Function>,
|
||||
is_none: bool,
|
||||
}
|
||||
|
||||
@@ -1094,7 +1137,9 @@ impl DeviceN {
|
||||
let num_components = u8::try_from(names.len()).ok()?;
|
||||
let all_none = names.iter().all(|n| n.as_str() == "None");
|
||||
let alternate_space = ColorSpace::new(iter.next::<Object<'_>>()?, cache)?;
|
||||
let tint_transform = Function::new(&iter.next::<Object<'_>>()?)?;
|
||||
// PdfCraft patch (55): an unreadable tint transform keeps the space (see
|
||||
// `eval_tint_transform`).
|
||||
let tint_transform = iter.next::<Object<'_>>().and_then(|f| Function::new(&f));
|
||||
|
||||
if num_components == 0 {
|
||||
return None;
|
||||
@@ -1113,12 +1158,11 @@ impl ToRgb for DeviceN {
|
||||
fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
|
||||
let colours = input.len().checked_div(usize::from(self.num_components));
|
||||
count_tint_transform_runs(colours.unwrap_or(0));
|
||||
// PdfCraft patch (55): see `eval_tint_transform`.
|
||||
let evaluated = input
|
||||
.chunks_exact(self.num_components as usize)
|
||||
.flat_map(|n| {
|
||||
self.tint_transform
|
||||
.eval(n.to_smallvec())
|
||||
.unwrap_or(self.alternate_space.initial_color())
|
||||
eval_tint_transform(self.tint_transform.as_ref(), &self.alternate_space, n)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.alternate_space.convert_f32(&evaluated, output, false)
|
||||
|
||||
+2
@@ -209,6 +209,8 @@ impl<'a> Context<'a> {
|
||||
NESTED_PAINTS.with(|n| n.set(0));
|
||||
CONTENT_LEFT.with(|c| c.set(MAX_PAGE_CONTENT));
|
||||
CONTENT_TRUNCATED.with(|truncated| truncated.set(false));
|
||||
// PdfCraft patch (55): a fallback left over from another page is not this page's.
|
||||
crate::color::take_color_fallback();
|
||||
crate::encode::reset_mesh_budget();
|
||||
|
||||
Self::new_with(initial_transform, bbox, cache, xref, settings, state, 0)
|
||||
|
||||
+3
-1
@@ -86,7 +86,9 @@ impl Type0Font {
|
||||
if let Some((standard, _)) = select_standard_font(dict, &font_descriptor) {
|
||||
(FontQuery::Standard(standard), true)
|
||||
} else {
|
||||
let mut query = FallbackFontQuery::new(dict);
|
||||
// PdfCraft patch: the style comes from the descendant's descriptor.
|
||||
let mut query =
|
||||
FallbackFontQuery::with_descriptor(dict, Some(font_descriptor.clone()));
|
||||
query.character_collection = character_collection.clone();
|
||||
|
||||
warn!(
|
||||
|
||||
+8
-1
@@ -564,6 +564,13 @@ pub struct FallbackFontQuery {
|
||||
|
||||
impl FallbackFontQuery {
|
||||
pub(crate) fn new(dict: &Dict<'_>) -> Self {
|
||||
Self::with_descriptor(dict, dict.get::<Dict<'_>>(FONT_DESC))
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the query for the font `dict` names, with its style read from
|
||||
/// `descriptor`. A Type 0 font has no font descriptor of its own; its descendant CIDFont
|
||||
/// has it (ISO 32000-2 §9.7.4.1, §9.7.6.1).
|
||||
pub(crate) fn with_descriptor(dict: &Dict<'_>, descriptor: Option<Dict<'_>>) -> Self {
|
||||
let post_script_name = dict
|
||||
.get::<Name<'_>>(BASE_FONT)
|
||||
.map(|n| strip_subset_prefix(n.as_str()).to_string());
|
||||
@@ -573,7 +580,7 @@ impl FallbackFontQuery {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(descriptor) = dict.get::<Dict<'_>>(FONT_DESC) {
|
||||
if let Some(descriptor) = descriptor {
|
||||
data.font_name = dict
|
||||
.get::<Name<'_>>(FONT_NAME)
|
||||
.map(|n| strip_subset_prefix(n.as_str()).to_string());
|
||||
|
||||
@@ -544,6 +544,15 @@ impl StandardKind {
|
||||
return path;
|
||||
};
|
||||
|
||||
// PdfCraft patch (57): a stand-in for a font the document names is narrowed to a
|
||||
// narrower /Widths advance, so neighbours don't overlap, but never widened: the
|
||||
// advance already places the next glyph, and widening drew `i` and `l` of a 600-unit
|
||||
// `Arial,Bold` as solid blocks twice their width.
|
||||
let should_width = if self.fallback {
|
||||
should_width.min(actual_width)
|
||||
} else {
|
||||
should_width
|
||||
};
|
||||
return stretch_glyph(path, should_width, actual_width);
|
||||
}
|
||||
|
||||
|
||||
+28
-18
@@ -360,26 +360,36 @@ impl EmbeddedKind {
|
||||
}
|
||||
}
|
||||
} else if let Ok(cmap) = self.base_font.font_ref().cmap() {
|
||||
for record in cmap.encoding_records() {
|
||||
if record.platform_id() == PlatformId::Windows
|
||||
&& matches!(record.encoding_id(), 0 | 1)
|
||||
{
|
||||
if let Ok(subtable) = record.subtable(cmap.offset_data()) {
|
||||
for offset in [0x0000_u32, 0xF000, 0xF100, 0xF200] {
|
||||
glyph = glyph
|
||||
.or_else(|| subtable.map_codepoint(code as u32 + offset))
|
||||
.filter(|g| *g != GlyphId::NOTDEF);
|
||||
// PdfCraft patch (#4156): the Windows (3, 0) subtable comes first, and the (1, 0) one
|
||||
// only for codes it doesn't map (ISO 32000-2 §9.6.5.4: "If the font contains a (3, 0)
|
||||
// subtable" it is used; "Otherwise, if the font contains a (1, 0) subtable"). The
|
||||
// records are listed by platform ID, so Macintosh came before Windows and a wrong
|
||||
// (1, 0) table, as SAS writes for its AMT fonts, drew "8" as "p" and "+" as "V".
|
||||
for windows_pass in [true, false] {
|
||||
for record in cmap.encoding_records() {
|
||||
let windows = record.platform_id() == PlatformId::Windows
|
||||
&& matches!(record.encoding_id(), 0 | 1);
|
||||
if windows != windows_pass {
|
||||
continue;
|
||||
}
|
||||
if windows {
|
||||
if let Ok(subtable) = record.subtable(cmap.offset_data()) {
|
||||
for offset in [0x0000_u32, 0xF000, 0xF100, 0xF200] {
|
||||
glyph = glyph
|
||||
.or_else(|| subtable.map_codepoint(code as u32 + offset))
|
||||
.filter(|g| *g != GlyphId::NOTDEF);
|
||||
}
|
||||
}
|
||||
} else if matches!(
|
||||
record.platform_id(),
|
||||
PlatformId::Macintosh | PlatformId::Unicode
|
||||
) && record.encoding_id() == 0
|
||||
&& let Ok(subtable) = record.subtable(cmap.offset_data())
|
||||
{
|
||||
glyph = glyph
|
||||
.or_else(|| subtable.map_codepoint(code))
|
||||
.filter(|g| *g != GlyphId::NOTDEF);
|
||||
}
|
||||
} else if matches!(
|
||||
record.platform_id(),
|
||||
PlatformId::Macintosh | PlatformId::Unicode
|
||||
) && record.encoding_id() == 0
|
||||
&& let Ok(subtable) = record.subtable(cmap.offset_data())
|
||||
{
|
||||
glyph = glyph
|
||||
.or_else(|| subtable.map_codepoint(code))
|
||||
.filter(|g| *g != GlyphId::NOTDEF);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+69
-2
@@ -157,6 +157,10 @@ pub enum InterpreterWarning {
|
||||
ImageDecodeFailure,
|
||||
/// PdfCraft patch (18): a page content stream was skipped after exhausting its safety budget.
|
||||
ContentTruncated,
|
||||
/// PdfCraft patch (55): a colour could not be computed (a colour space that cannot be
|
||||
/// read, or a Separation or DeviceN tint transform that cannot be evaluated) and was painted
|
||||
/// in a fallback colour. Reported at most once per content stream.
|
||||
ColorFallback,
|
||||
}
|
||||
|
||||
/// interpret the contents of the page and render them into the device.
|
||||
@@ -186,6 +190,15 @@ pub fn interpret_page<'a>(
|
||||
if flags & (2 | 32) != 0 {
|
||||
continue;
|
||||
}
|
||||
// PdfCraft patch (56): Invisible (bit 1) hides an annotation of a type that isn't one
|
||||
// of the standard ones (ISO 32000-2 Table 167), and a popup has no appearance of its
|
||||
// own (§12.5.6.14): the viewer shows it as a window for its parent, not on the page.
|
||||
let subtype = annot.get::<hayro_syntax::object::Name<'_>>(SUBTYPE);
|
||||
if subtype.as_deref() == Some(b"Popup")
|
||||
|| (flags & 1 != 0 && !subtype.as_deref().is_some_and(is_standard_annotation_type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// PdfCraft patch: "Hide all comments" keeps widgets and links.
|
||||
if context.settings.hide_comments
|
||||
&& !matches!(annot.get::<hayro_syntax::object::Name<'_>>(SUBTYPE).as_deref(), Some(b"Widget" | b"Link"))
|
||||
@@ -282,6 +295,42 @@ pub fn interpret_page<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (56): whether `subtype` is one of the standard annotation types of
|
||||
/// ISO 32000-2 Table 171.
|
||||
fn is_standard_annotation_type(subtype: &[u8]) -> bool {
|
||||
matches!(
|
||||
subtype,
|
||||
b"Text"
|
||||
| b"Link"
|
||||
| b"FreeText"
|
||||
| b"Line"
|
||||
| b"Square"
|
||||
| b"Circle"
|
||||
| b"Polygon"
|
||||
| b"PolyLine"
|
||||
| b"Highlight"
|
||||
| b"Underline"
|
||||
| b"Squiggly"
|
||||
| b"StrikeOut"
|
||||
| b"Caret"
|
||||
| b"Stamp"
|
||||
| b"Ink"
|
||||
| b"Popup"
|
||||
| b"FileAttachment"
|
||||
| b"Sound"
|
||||
| b"Movie"
|
||||
| b"Screen"
|
||||
| b"Widget"
|
||||
| b"PrinterMark"
|
||||
| b"TrapNet"
|
||||
| b"Watermark"
|
||||
| b"3D"
|
||||
| b"Redact"
|
||||
| b"Projection"
|
||||
| b"RichMedia"
|
||||
)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the normal appearance of a Highlight annotation that has none (ISO 32000-2
|
||||
/// §12.5.6.10), drawn as PdfCraft draws its own highlights (pdfcraft-annot): each /QuadPoints
|
||||
/// quadrilateral filled with /C at /CA opacity, blended with Multiply so the marked-up content
|
||||
@@ -562,8 +611,13 @@ fn interpret_window<'a>(
|
||||
}
|
||||
TypedInstruction::ColorSpaceStroke(c) => {
|
||||
// PdfCraft patch (38): see `named_color_space`.
|
||||
// PdfCraft patch (55): a space that cannot be read is drawn in DeviceGray, as
|
||||
// before, and the fallback is reported.
|
||||
let cs = named_color_space(c.0, resources, &defaults, context)
|
||||
.unwrap_or(ColorSpace::device_gray());
|
||||
.unwrap_or_else(|| {
|
||||
crate::color::note_color_fallback();
|
||||
ColorSpace::device_gray()
|
||||
});
|
||||
|
||||
if !cs.is_pattern() {
|
||||
context.get_mut().graphics_state.stroke_pattern = None;
|
||||
@@ -573,8 +627,13 @@ fn interpret_window<'a>(
|
||||
}
|
||||
TypedInstruction::ColorSpaceNonStroke(c) => {
|
||||
// PdfCraft patch (38): see `named_color_space`.
|
||||
// PdfCraft patch (55): a space that cannot be read is drawn in DeviceGray, as
|
||||
// before, and the fallback is reported.
|
||||
let cs = named_color_space(c.0, resources, &defaults, context)
|
||||
.unwrap_or(ColorSpace::device_gray());
|
||||
.unwrap_or_else(|| {
|
||||
crate::color::note_color_fallback();
|
||||
ColorSpace::device_gray()
|
||||
});
|
||||
|
||||
if !cs.is_pattern() {
|
||||
context.get_mut().graphics_state.non_stroke_pattern = None;
|
||||
@@ -932,6 +991,10 @@ pub fn interpret<'a>(
|
||||
if crate::context::content_was_truncated() {
|
||||
(context.settings.warning_sink)(InterpreterWarning::ContentTruncated);
|
||||
}
|
||||
// PdfCraft patch (55): and that a colour was painted in a fallback colour.
|
||||
if crate::color::take_color_fallback() {
|
||||
(context.settings.warning_sink)(InterpreterWarning::ColorFallback);
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the bytes of a window of page content. A window grows only while an instruction
|
||||
@@ -990,4 +1053,8 @@ fn interpret_content<'a>(
|
||||
if cut || content.truncated() || crate::context::content_was_truncated() {
|
||||
(context.settings.warning_sink)(InterpreterWarning::ContentTruncated);
|
||||
}
|
||||
// PdfCraft patch (55): and that a colour was painted in a fallback colour.
|
||||
if crate::color::take_color_fallback() {
|
||||
(context.settings.warning_sink)(InterpreterWarning::ColorFallback);
|
||||
}
|
||||
}
|
||||
+56
-17
@@ -37,12 +37,11 @@ use crate::content::ops::TypedInstruction;
|
||||
use crate::object;
|
||||
use crate::object::dict::{Dict, InlineImageDict};
|
||||
use crate::object::name::{Name, skip_name_like};
|
||||
use crate::object::{Array, Null, Number, Object, Stream};
|
||||
use crate::object::{Array, Number, Object, Stream};
|
||||
use crate::reader::Reader;
|
||||
use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable};
|
||||
use crate::trivia::{Comment, is_regular_character, is_white_space_character};
|
||||
use crate::util::find_needle;
|
||||
use core::array;
|
||||
use core::fmt::{Debug, Formatter};
|
||||
use core::ops::Deref;
|
||||
use core::marker::PhantomData;
|
||||
@@ -51,8 +50,18 @@ use smallvec::SmallVec;
|
||||
// 6 operands are used for example for ctm or cubic curves,
|
||||
// but anything above should be pretty rare (only for example for
|
||||
// DeviceN color spaces, or invalid PDF files). So we settle on 10.
|
||||
// PdfCraft patch (28): this is now only how many operands are held inline; see `MAX_OPERANDS`.
|
||||
const OPERANDS_THRESHOLD: usize = 10;
|
||||
|
||||
/// PdfCraft patch (28): the most operands one instruction keeps. The stack held ten, and the
|
||||
/// eleventh operand of an instruction ended the whole content stream, so a DeviceN colour of
|
||||
/// eleven or more inks (ISO 32000-2 §8.6.6.5 allows "an arbitrary number of colour
|
||||
/// components") blanked the rest of the page (#4260). An `SCN`/`scn` for the largest DeviceN
|
||||
/// space the interpreter reads (255 inks) takes 255 numbers and a pattern name. Past this, the
|
||||
/// oldest operands are dropped: operators read the operands nearest to them (see `shift` in the
|
||||
/// operator macros), so this is how stray operands below an instruction are already treated.
|
||||
const MAX_OPERANDS: usize = 256;
|
||||
|
||||
impl Debug for Operator<'_> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
|
||||
write!(f, "{}", self.0.as_str())
|
||||
@@ -436,6 +445,8 @@ impl<'a> UntypedIter<'a> {
|
||||
|
||||
self.operator = Some(operator);
|
||||
self.committed = self.reader.offset();
|
||||
// PdfCraft patch (28): operands in reading order, if some were dropped.
|
||||
self.stack.settle();
|
||||
return Some(Instruction {
|
||||
operands: &self.stack,
|
||||
operator: self.operator.as_ref().unwrap(),
|
||||
@@ -594,8 +605,13 @@ impl<'b, 'a> Instruction<'b, 'a> {
|
||||
pub struct Stack<'a> {
|
||||
// TODO: Explore using an object pool to avoid repeatedly
|
||||
// allocating/deallocating objects.
|
||||
data: [Object<'a>; OPERANDS_THRESHOLD],
|
||||
len: usize,
|
||||
// PdfCraft patch (28): up to `MAX_OPERANDS` operands, the first `OPERANDS_THRESHOLD` inline.
|
||||
data: SmallVec<[Object<'a>; OPERANDS_THRESHOLD]>,
|
||||
/// PdfCraft patch (28): once `data` is full, the slot of its oldest operand, which the next
|
||||
/// operand replaces. Always 0 while the stack is not full, and after `settle`.
|
||||
oldest: usize,
|
||||
/// PdfCraft patch (28): whether operands were dropped since the stack was last settled.
|
||||
dropped: bool,
|
||||
}
|
||||
|
||||
impl<'a> Default for Stack<'a> {
|
||||
@@ -608,31 +624,53 @@ impl<'a> Stack<'a> {
|
||||
/// Create a new, empty stack.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
data: array::from_fn(|_| Object::Null(Null)),
|
||||
len: 0,
|
||||
data: SmallVec::new(),
|
||||
oldest: 0,
|
||||
dropped: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (28): push an operand, dropping the oldest once `MAX_OPERANDS` are held
|
||||
/// (see `MAX_OPERANDS`). Never fails: it used to, and the failure ended the content stream.
|
||||
fn push(&mut self, operand: Object<'a>) -> Option<()> {
|
||||
if self.len >= OPERANDS_THRESHOLD {
|
||||
return None;
|
||||
if self.data.len() < MAX_OPERANDS {
|
||||
self.data.push(operand);
|
||||
return Some(());
|
||||
}
|
||||
|
||||
self.data[self.len] = operand;
|
||||
self.len += 1;
|
||||
self.dropped = true;
|
||||
if let Some(slot) = self.data.get_mut(self.oldest) {
|
||||
*slot = operand;
|
||||
}
|
||||
self.oldest = (self.oldest + 1) % MAX_OPERANDS;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// PdfCraft patch (28): put the operands back in the order they were read, oldest first,
|
||||
/// after `push` dropped some. Called before the instruction is handed out.
|
||||
fn settle(&mut self) {
|
||||
if self.dropped {
|
||||
warn!("more than {MAX_OPERANDS} operands before an operator; the oldest were dropped");
|
||||
// Cannot panic: operands are dropped only once `data` holds `MAX_OPERANDS`, and
|
||||
// `oldest` stays below that.
|
||||
self.data.rotate_left(self.oldest);
|
||||
self.oldest = 0;
|
||||
self.dropped = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.len = 0;
|
||||
self.data.clear();
|
||||
self.oldest = 0;
|
||||
self.dropped = false;
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.len
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
fn as_slice(&self) -> &[Object<'a>] {
|
||||
&self.data[..self.len]
|
||||
&self.data
|
||||
}
|
||||
|
||||
fn get<'b, T>(&'b self, index: usize) -> Option<T>
|
||||
@@ -665,11 +703,12 @@ impl Debug for Stack<'_> {
|
||||
|
||||
impl Clone for Stack<'_> {
|
||||
fn clone(&self) -> Self {
|
||||
let mut stack = Self::new();
|
||||
for item in self.as_slice() {
|
||||
stack.push(item.clone()).unwrap();
|
||||
// PdfCraft patch (28): a plain copy (pushing each operand again could no longer fail).
|
||||
Self {
|
||||
data: self.data.clone(),
|
||||
oldest: self.oldest,
|
||||
dropped: self.dropped,
|
||||
}
|
||||
stack
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
@@ -152,6 +152,47 @@ f
|
||||
assert!(iter.next().is_none());
|
||||
}
|
||||
|
||||
/// PdfCraft patch (28): a twelve-ink DeviceN colour's `scn` keeps all twelve operands, and
|
||||
/// the instructions after it are still read (#4260). The eleventh operand ended the stream.
|
||||
#[test]
|
||||
fn scn_with_twelve_components_keeps_them_all() {
|
||||
let input = b"1 2 3 4 5 6 7 8 9 10 11 12 scn 0 0 10 10 re f";
|
||||
let mut iter = TypedIter::new(input);
|
||||
|
||||
match iter.next() {
|
||||
Some(TypedInstruction::NonStrokeColorNamed(NonStrokeColorNamed(nums, None))) => {
|
||||
let expected = (1..=12).map(Number::from_i32).collect::<Vec<_>>();
|
||||
assert_eq!(nums.as_slice(), expected.as_slice());
|
||||
}
|
||||
other => panic!("unexpected instruction: {other:?}"),
|
||||
}
|
||||
assert!(matches!(iter.next(), Some(TypedInstruction::RectPath(_))));
|
||||
assert!(matches!(iter.next(), Some(TypedInstruction::FillPathNonZero(_))));
|
||||
assert!(iter.next().is_none());
|
||||
}
|
||||
|
||||
/// PdfCraft patch (28): past the operand cap the oldest operands are dropped, in order, and
|
||||
/// the operator reads the ones nearest to it.
|
||||
#[test]
|
||||
fn operands_past_the_cap_keep_the_latest_in_order() {
|
||||
let mut input = String::new();
|
||||
for i in 0..600 {
|
||||
input.push_str(&format!("{i} "));
|
||||
}
|
||||
input.push_str("scn 0 1 0 rg");
|
||||
let mut iter = TypedIter::new(input.as_bytes());
|
||||
|
||||
match iter.next() {
|
||||
Some(TypedInstruction::NonStrokeColorNamed(NonStrokeColorNamed(nums, None))) => {
|
||||
let expected = (344..600).map(Number::from_i32).collect::<Vec<_>>();
|
||||
assert_eq!(nums.as_slice(), expected.as_slice());
|
||||
}
|
||||
other => panic!("unexpected instruction: {other:?}"),
|
||||
}
|
||||
assert!(matches!(iter.next(), Some(TypedInstruction::NonStrokeColorDeviceRgb(_))));
|
||||
assert!(iter.next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scn() {
|
||||
let input = b"
|
||||
|
||||
Reference in new issue
Block a user