From 38d692089fb3b494737d0a7dca7c2cec66b23c94 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:46:12 -0600 Subject: [PATCH 1/6] PDF: preserve placed optional-content visibility in vector exports Keep source defaults and per-frame hidden layers in plain and PDF/X-4 vector exports, including independent repeated placements, booklet output and book merging. Accept usage-application arrays and resource-only groups without a catalog configuration. Use file-named warnings and all-ON defaults for recoverable default-configuration problems and unsupported base states. Add readable groups omitted from OCGs without discarding their visibility, combine concurrent recovery warnings, and generate missing names only when no frame override needs name matching. Retain errors for contradictory states, unresolved references and ambiguous name overrides. Cache prepared variants per asset and hidden-name set while replaying recovery warnings through the existing deduplicating channel. Keep original source bytes when every group is visible and named, so reader-repaired cross-reference tables remain usable without an incremental layer update. When hidden groups or generated names require writing, reject an unusable cross-reference table with a file-named damage error and retain the first placement failure. Cover visibility, recovery, names, independent placements, repaired cross-reference tables and vector output with 46 optional-content integration tests, plus preparation-cache and shared-buffer unit tests. --- ROADMAP.md | 4 +- crates/engine/src/cmd/export.rs | 3 +- crates/engine/src/cmd/pdflayers.rs | 8 +- crates/engine/tests/pdf_optional_content.rs | 238 +++++++++++++++ .../engine/tests/support/optional_content.rs | 173 +++++++++++ .../support/optional_content_fallback.rs | 263 +++++++++++++++++ .../tests/support/optional_content_xref.rs | 143 +++++++++ crates/pdf/src/export.rs | 134 ++++++++- crates/pdf/src/lib.rs | 1 + crates/pdf/src/optional_content.rs | 279 ++++++++++++++++++ crates/pdf/src/registration_tests.rs | 204 +++++++++++++ crates/pdf/src/text.rs | 2 +- docs/target-app-parity.md | 3 +- 13 files changed, 1433 insertions(+), 22 deletions(-) create mode 100644 crates/engine/tests/pdf_optional_content.rs create mode 100644 crates/engine/tests/support/optional_content.rs create mode 100644 crates/engine/tests/support/optional_content_fallback.rs create mode 100644 crates/engine/tests/support/optional_content_xref.rs create mode 100644 crates/pdf/src/optional_content.rs create mode 100644 crates/pdf/src/registration_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 153555913..38397898c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,7 +132,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. - Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. -- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. +- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; plain and PDF/X-4 exports preserve their visible layers as vectors. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). - Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. - View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups). @@ -387,6 +387,8 @@ Newest first. - **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change. - **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change. +- **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. +- **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. - **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. - **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. - **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 551dd7484..52886c33c 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -733,9 +733,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { ), }; let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)); - // Placed PDFs with hidden layers go out as images showing just their visible layers. let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); - let (layered, layer_count) = rasterize_layered(base)?; + let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) }; let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache); let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 937fe81c0..c2543c3f2 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -374,8 +374,12 @@ pub(crate) mod tests { assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}"); assert!(px(&s, 125)[2] > 200, "blue still shows"); assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); - // PDF export carries the choice (the graphic goes out as an image). let r = s.execute("file.exportPdf", &json!({})).unwrap(); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}"); + assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}"); + let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + let image = designcraft_render::render_pdf_page(&bytes, 0, 792).unwrap(); + let width = usize::from(image.width()); + assert!(image.data()[150 * width + 125].a > 200); + assert_eq!(image.data()[150 * width + 175].a, 0); } } diff --git a/crates/engine/tests/pdf_optional_content.rs b/crates/engine/tests/pdf_optional_content.rs new file mode 100644 index 000000000..e96e34915 --- /dev/null +++ b/crates/engine/tests/pdf_optional_content.rs @@ -0,0 +1,238 @@ +#[path = "support/optional_content.rs"] +mod support; + +#[path = "support/optional_content_fallback.rs"] +mod fallback; + +#[path = "support/optional_content_xref.rs"] +mod xref; + +use designcraft_compose::Cache; +use designcraft_pdf::{PdfOptions, Standard, export_pdf, export_pdf_with_report}; +use support::{catalog, document, fixture, painted}; + +const DEFAULT: &str = "/BaseState/ON/OFF[6 3 R]"; +const USAGE_APPLICATIONS: &str = "/BaseState/ON/ON[5 0 R]/OFF[6 3 R]/AS[<><><>]"; + +#[test] +fn source_layer_dictionaries_are_readable() { + let pdf = hayro_syntax::Pdf::new(fixture(DEFAULT, "Hidden")).unwrap(); + for id in [(5, 0), (6, 3)] { + assert!(pdf.xref().get::>(hayro_syntax::object::ObjectIdentifier::new(id.0, id.1)).is_some(), "{id:?}"); + } +} + +fn exported(config: &str, standard: Standard) -> Vec { + let doc = document(fixture(config, "Hidden"), &[&[]]); + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, created: Some(0), ..Default::default() }).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + report.bytes +} + +#[test] +fn source_default_off_renders_before_export() { + let bytes = fixture(DEFAULT, "Hidden"); + + painted(&bytes, &[true, false]); +} + +#[test] +fn plain_export_registers_default_off_layers() { + let bytes = exported(DEFAULT, Standard::None); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); +} + +#[test] +fn pdfx_export_preserves_default_off_catalog() { + let bytes = exported(DEFAULT, Standard::PdfX4); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); +} + +#[test] +fn plain_export_paints_only_the_shown_layer() { + let bytes = exported(DEFAULT, Standard::None); + + painted(&bytes, &[true, false]); +} + +#[test] +fn pdfx_export_paints_only_the_shown_layer() { + let bytes = exported(DEFAULT, Standard::PdfX4); + + painted(&bytes, &[true, false]); +} + +#[test] +fn plain_export_preserves_defaults_with_usage_applications() { + let bytes = exported(USAGE_APPLICATIONS, Standard::None); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert_vector(&bytes); + painted(&bytes, &[true, false]); +} + +#[test] +fn pdfx_export_preserves_defaults_with_usage_applications() { + let bytes = exported(USAGE_APPLICATIONS, Standard::PdfX4); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + assert_vector(&bytes); + painted(&bytes, &[true, false]); +} + +#[test] +fn plain_export_draws_groups_without_catalog_properties() { + let doc = document(support::orphaned_fixture(), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + assert_vector(&bytes); + painted(&bytes, &[true, true]); +} + +#[test] +fn pdfx_export_draws_groups_without_catalog_properties() { + let doc = document(support::orphaned_fixture(), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + assert_vector(&bytes); + painted(&bytes, &[true, true]); +} + +#[test] +fn base_off_with_on_exception_is_preserved() { + let bytes = exported("/BaseState/OFF/ON[5 0 R]", Standard::None); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); +} + +#[test] +fn same_asset_placements_keep_opposing_layer_states() { + let doc = document(fixture(DEFAULT, "Hidden"), &[&[], &["Shown"]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Hidden".into(), true), ("Shown".into(), false), ("Shown".into(), true)]); + painted(&bytes, &[true, false, false, true]); +} + +#[test] +fn duplicate_names_with_distinct_generations_keep_default_states() { + let doc = document(fixture(DEFAULT, "Shown"), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Shown".into(), false), ("Shown".into(), true)]); + painted(&bytes, &[true, false]); +} + +#[test] +fn unresolved_default_off_reference_fails_export() { + let doc = document(fixture("/OFF[99 0 R]", "Hidden"), &[&[]]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions::default()); + + assert!(result.is_err(), "an unreadable layer state must not print hidden artwork"); +} + +#[test] +fn unlayered_pdf_stays_vector_without_optional_content() { + let doc = document(support::unlayered_fixture(), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let root = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + assert!(!root.contains_key(b"OCProperties")); + assert_vector(&bytes); + painted(&bytes, &[true, false]); +} + +fn assert_vector(bytes: &[u8]) { + use hayro_syntax::object::{Name, Object}; + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + for object in pdf.objects() { + if let Object::Stream(stream) = object { + assert_ne!(stream.dict().get::>(b"Subtype").as_ref().map(AsRef::as_ref), Some(b"Image".as_slice())); + } + } +} + +#[test] +fn layer_options_command_preserves_vector_overrides_in_plain_and_pdfx() { + use designcraft_engine::{ + Session, + cmd::{base64_decode, base64_encode}, + }; + use serde_json::json; + for standard in ["none", "x4"] { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session + .execute("file.place", &json!({"base64": base64_encode(&fixture(DEFAULT, "Hidden")), "name": "layers.pdf", "x": 0, "y": 0, "width": 100})) + .unwrap(); + session.execute("object.layerOptions", &json!({"hidden": ["Shown"]})).unwrap(); + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let bytes = base64_decode(result["base64"].as_str().unwrap()); + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), true)]); + assert_vector(&bytes); + painted(&bytes, &[false, true]); + } +} + +#[test] +fn exported_optional_content_survives_replacement_and_book_merge() { + let first = exported(DEFAULT, Standard::None); + let doc = document(first.clone(), &[&[]]); + + let replaced = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + let merged = designcraft_pdf::merge_pdfs(&[(first, vec![(100.0, 100.0)])], None).unwrap(); + + for bytes in [replaced, merged] { + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); + } +} + +#[test] +fn booklet_export_preserves_optional_content() { + let doc = document(fixture(DEFAULT, "Hidden"), &[&[]]); + let options = designcraft_pdf::BookletOptions { kind: designcraft_pdf::BookletKind::TwoUpConsecutive, ..Default::default() }; + + let report = designcraft_pdf::export_booklet(&doc, &Cache::new(), &options).unwrap(); + + assert!(report.warnings.is_empty()); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert_vector(&report.bytes); + painted(&report.bytes, &[true, false, false, false]); +} + +#[test] +fn archival_export_still_rejects_unvalidated_placed_pdf() { + let doc = document(fixture(DEFAULT, "Hidden"), &[&[]]); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..Default::default() }).unwrap_err(); + + assert!(error.to_string().contains("EmbeddedPDF"), "{error}"); +} + +#[test] +fn source_xref_stream_preserves_layer_states() { + let doc = document(support::stream_fixture(DEFAULT), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); +} diff --git a/crates/engine/tests/support/optional_content.rs b/crates/engine/tests/support/optional_content.rs new file mode 100644 index 000000000..3ed73d74f --- /dev/null +++ b/crates/engine/tests/support/optional_content.rs @@ -0,0 +1,173 @@ +use std::sync::Arc; + +use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef, build::NewDocument}; +use designcraft_geom::{Affine, Rect, shapes}; +use hayro_syntax::{ + Pdf, + object::{Array, Dict, Name, ObjectIdentifier}, +}; + +pub fn fixture(config: &str, second_name: &str) -> Vec { + layered_fixture(Some(config), second_name) +} + +pub fn orphaned_fixture() -> Vec { + layered_fixture(None, "Hidden") +} + +fn layered_fixture(config: Option<&str>, second_name: &str) -> Vec { + let properties = config.map(|config| format!("/OCProperties<>>>")).unwrap_or_default(); + properties_fixture(&properties, second_name) +} + +pub fn properties_fixture(properties: &str, second_name: &str) -> Vec { + grouped_fixture(properties, Some(second_name)) +} + +pub fn nameless_fixture(config: &str) -> Vec { + grouped_fixture(&format!("/OCProperties<>>>"), None) +} + +fn grouped_fixture(properties: &str, second_name: Option<&str>) -> Vec { + let content = "/OC /a BDC 1 0 0 0 k 0 0 50 100 re f EMC /OC /b BDC 0 1 0 0 k 50 0 50 100 re f EMC"; + let name = second_name.map(|name| format!("/Name({name})")).unwrap_or_default(); + let objects = [ + format!("<>"), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>>>/Custom(keep \\(this\\) >>)>>".into(), + format!("<>>>/Custom<00ff80>>>"), + ]; + assemble(&objects, &[2, 0, 0, 0, 0, 3]) +} + +pub fn unlayered_fixture() -> Vec { + let content = "1 0 0 0 k 0 0 50 100 re f"; + assemble( + &[ + "<>".into(), + "<>".into(), + "<>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ], + &[2, 0, 0, 0], + ) +} + +pub fn stream_fixture(config: &str) -> Vec { + let mut bytes = fixture(config, "Hidden"); + let tail = std::str::from_utf8(&bytes).unwrap(); + let previous: usize = tail.rsplit("startxref").next().unwrap().split_whitespace().next().unwrap().parse().unwrap(); + let offset = bytes.len(); + let mut entry = vec![1]; + entry.extend_from_slice(&u32::try_from(offset).unwrap().to_be_bytes()); + entry.extend_from_slice(&0u16.to_be_bytes()); + bytes.extend_from_slice(format!("7 0 obj\n<>\nstream\n").as_bytes()); + bytes.extend_from_slice(&entry); + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + bytes +} + +fn assemble(objects: &[String], generations: &[i32]) -> Vec { + let mut bytes = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for (index, body) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} {} obj\n{body}\nendobj\n", index + 1, generations[index]).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for (offset, generation) in offsets.iter().zip(generations) { + bytes.extend_from_slice(format!("{offset:010} {generation:05} n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +pub fn document(bytes: Vec, hidden_sets: &[&[&str]]) -> Document { + let width = 100.0 * f64::from(u32::try_from(hidden_sets.len()).unwrap()); + let mut doc = Document::new(&NewDocument { width, height: 100.0, facing_pages: false, ..Default::default() }); + let asset = AssetId(doc.alloc()); + doc.add_asset(Asset { + id: asset, + name: "layers.pdf".into(), + mime: "application/pdf".into(), + data: Arc::new(bytes), + link: None, + pixels: None, + page: 0, + pdf_crop: Default::default(), + pdf_box: None, + }); + for (index, hidden) in hidden_sets.iter().enumerate() { + let x = 100.0 * f64::from(u32::try_from(index).unwrap()); + let mut item = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + item.xf = Affine::translate((x, 0.0)); + item.pdf_hidden_layers = hidden.iter().map(|name| (*name).to_owned()).collect(); + item.content = Content::Graphic(Graphic { + asset, + size: (100.0, 100.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + doc +} + +pub fn refs(dict: &Dict<'_>, key: &[u8]) -> Vec { + dict.get::>(key).unwrap().raw_iter().map(|value| value.as_obj_ref().unwrap().into()).collect() +} + +pub fn catalog(bytes: &[u8]) -> Vec<(String, bool)> { + let pdf = Pdf::new(bytes.to_vec()).unwrap(); + let root = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + let properties = root.get::>(b"OCProperties").expect("export must register its embedded OCGs"); + let groups = refs(&properties, b"OCGs"); + let defaults = properties.get::>(b"D").unwrap(); + assert!(defaults.get::>(b"Name").is_some()); + assert_eq!(defaults.get::>(b"BaseState").unwrap().as_ref(), b"ON"); + assert!(!defaults.contains_key(b"AS")); + assert_eq!(refs(&defaults, b"Order"), groups); + let off = refs(&defaults, b"OFF"); + assert!(off.iter().all(|id| groups.contains(id))); + let mut unique = groups.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), groups.len()); + let mut result = Vec::new(); + for id in groups { + let group = pdf.xref().get::>(id).unwrap(); + assert!(group.contains_key(b"Usage")); + assert!(group.contains_key(b"Intent")); + assert!(group.contains_key(b"Custom")); + let custom = group.get::>(b"Custom").unwrap(); + assert!([b"keep (this) >>".as_slice(), &[0, 255, 128]].contains(&custom.as_bytes())); + let intent = group.get::>(b"Intent").unwrap(); + assert_eq!(intent.iter::>().map(|name| name.as_ref().to_vec()).collect::>(), [b"View".to_vec(), b"Design".to_vec()]); + let usage = group.get::>(b"Usage").unwrap().get::>(b"Print").unwrap(); + assert_eq!(usage.get::>(b"PrintState").unwrap().as_ref(), b"ON"); + let name = group.get::>(b"Name").unwrap(); + result.push((String::from_utf8(name.as_bytes().to_vec()).unwrap(), off.contains(&id))); + } + result.sort(); + result +} + +pub fn painted(bytes: &[u8], expected: &[bool]) { + let width = u32::try_from(expected.len()).unwrap() * 50; + let image = designcraft_render::render_pdf_page(bytes, 0, width.max(100)).unwrap(); + for (index, painted) in expected.iter().enumerate() { + let image_width = usize::from(image.width()); + let x = (2 * index + 1) * image_width / (2 * expected.len()); + let y = usize::from(image.height()) / 2; + let pixel = image.data()[y * image_width + x]; + assert_eq!(pixel.a > 200, *painted, "half {index}: {pixel:?}"); + } +} diff --git a/crates/engine/tests/support/optional_content_fallback.rs b/crates/engine/tests/support/optional_content_fallback.rs new file mode 100644 index 000000000..5a94bb7b1 --- /dev/null +++ b/crates/engine/tests/support/optional_content_fallback.rs @@ -0,0 +1,263 @@ +use super::support::{catalog, document, nameless_fixture, painted, properties_fixture}; +use designcraft_compose::Cache; +use designcraft_engine::{Session, cmd::base64_encode}; +use designcraft_pdf::{PdfOptions, Standard, export_pdf, export_pdf_with_report}; +use serde_json::json; + +const CASES: &[&str] = &[ + "/OCGs[5 0 R 6 3 R]", + "/OCGs[5 0 R 6 3 R]/D null", + "/OCGs[5 0 R 6 3 R]/D 42", + "/OCGs[5 0 R 6 3 R]/D 99 0 R", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", +]; + +fn incomplete_inventory(standard: &str, off: bool) { + let cases: &[&str] = if off { + &["/OCGs[5 0 R]/D<>", "/D<>"] + } else { + &["/OCGs[6 3 R]/D<>"] + }; + for properties in cases { + let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session.execute("file.place", &json!({"base64": base64_encode(&source), "name": "incomplete.pdf", "x": 0, "y": 0, "width": 100})).unwrap(); + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap()); + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); + super::assert_vector(&bytes); + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1); + let warning = warnings[0].as_str().unwrap(); + assert!(warning.contains("incomplete.pdf") && warning.contains("layer list incomplete"), "{warning}"); + assert!(!warning.contains("layer state not readable"), "{warning}"); + if standard == "x4" { + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + } + } +} + +#[test] +fn plain_incomplete_inventory_preserves_off() { + incomplete_inventory("none", true); +} + +#[test] +fn pdfx_incomplete_inventory_preserves_off() { + incomplete_inventory("x4", true); +} + +#[test] +fn plain_incomplete_inventory_preserves_on() { + incomplete_inventory("none", false); +} + +#[test] +fn pdfx_incomplete_inventory_preserves_on() { + incomplete_inventory("x4", false); +} + +#[test] +fn incomplete_inventory_reports_both_warnings_during_fallback() { + for standard in [Standard::None, Standard::PdfX4] { + let source = properties_fixture("/OCProperties<>>>", "Hidden"); + let doc = document(source, &[&[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + + assert_eq!(report.warnings.len(), 1); + let warning = &report.warnings[0]; + assert!( + warning.contains("layers.pdf") && warning.contains("layer list incomplete") && warning.contains("layer state not readable"), + "{warning}" + ); + painted(&report.bytes, &[true, true]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } + } +} + +fn visible_recovery(source: Vec, standard: &str) { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session.execute("file.place", &json!({"base64": base64_encode(&source), "name": "visible-recovery.pdf", "x": 0, "y": 0, "width": 100})).unwrap(); + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].as_str().unwrap().contains("visible-recovery.pdf")); + let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap()); + let groups = catalog(&bytes); + assert_eq!(groups.len(), 2); + assert!(groups.iter().all(|(name, hidden)| !name.is_empty() && !hidden)); + super::assert_vector(&bytes); + painted(&bytes, &[true, true]); + if standard == "x4" { + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + } +} + +#[test] +fn plain_export_recovers_malformed_on_when_defaults_are_visible() { + visible_recovery(super::support::fixture("/BaseState/ON/ON 42", "Hidden"), "none"); +} + +#[test] +fn pdfx_export_recovers_malformed_on_when_defaults_are_visible() { + visible_recovery(super::support::fixture("/BaseState/ON/ON 42", "Hidden"), "x4"); +} + +#[test] +fn plain_export_names_visible_group_when_display_name_is_missing() { + visible_recovery(nameless_fixture("/BaseState/ON"), "none"); +} + +#[test] +fn pdfx_export_names_visible_group_when_display_name_is_missing() { + visible_recovery(nameless_fixture("/BaseState/ON"), "x4"); +} + +#[test] +fn missing_display_name_fails_when_an_override_could_match() { + for standard in [Standard::None, Standard::PdfX4] { + let doc = document(nameless_fixture("/BaseState/ON"), &[&["Shown"]]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }); + + assert!(result.is_err(), "a nameless group could match the explicit hidden name"); + } +} + +#[test] +fn malformed_on_fails_when_base_state_can_hide_groups() { + for standard in [Standard::None, Standard::PdfX4] { + let doc = document(super::support::fixture("/BaseState/OFF/ON 42", "Hidden"), &[&[]]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }); + + assert!(result.is_err(), "all-ON recovery would expose groups hidden by BaseState OFF"); + } +} + +#[test] +fn name_repair_preserves_readable_default_off_state() { + for standard in [Standard::None, Standard::PdfX4] { + let doc = document(nameless_fixture("/BaseState/ON/OFF[6 3 R]"), &[&[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + + assert_eq!(report.warnings.len(), 1); + assert!(report.warnings[0].contains("layers.pdf")); + assert_eq!(catalog(&report.bytes).iter().filter(|(_, hidden)| *hidden).count(), 1); + painted(&report.bytes, &[true, false]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } + } +} + +fn fallback_exports(standard: Standard) { + let mut failures = Vec::new(); + for properties in CASES { + let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"); + let doc = document(source, &[&[], &["Shown"]]); + + let report = match export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, created: Some(0), ..Default::default() }) { + Ok(report) => report, + Err(error) => { + failures.push(format!("{properties}: {error}")); + continue; + } + }; + + if report.warnings.len() != 1 { + failures.push(format!("{properties}: expected one fallback warning, got {:?}", report.warnings)); + continue; + } + assert!(report.warnings[0].contains("layers.pdf")); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Hidden".into(), false), ("Shown".into(), false), ("Shown".into(), true)]); + super::assert_vector(&report.bytes); + painted(&report.bytes, &[true, true, false, true]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } + } + assert!(failures.is_empty(), "fallback export failures: {failures:#?}"); +} + +#[test] +fn plain_fallback_shows_all_layers_and_preserves_frame_overrides() { + fallback_exports(Standard::None); +} + +#[test] +fn pdfx_fallback_shows_all_layers_and_preserves_frame_overrides() { + fallback_exports(Standard::PdfX4); +} + +#[test] +fn file_export_reports_fallback_with_filename_and_preserves_overrides() { + for properties in CASES { + for standard in ["none", "x4"] { + for hidden in [false, true] { + let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session + .execute("file.place", &json!({"base64": base64_encode(&source), "name": "fallback.pdf", "x": 0, "y": 0, "width": 100})) + .unwrap(); + if hidden { + session.execute("object.layerOptions", &json!({"hidden": ["Shown"]})).unwrap(); + } + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{properties}: {result}"); + assert!(warnings[0].as_str().unwrap().contains("fallback.pdf")); + let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap()); + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), hidden)]); + painted(&bytes, &[!hidden, true]); + } + } + } +} + +#[test] +fn contradictory_and_unresolved_states_fail_even_with_fallback_or_overrides() { + for properties in [ + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R 99 0 R]", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + ] { + for standard in [Standard::None, Standard::PdfX4] { + for hidden in [&[][..], &["Shown"][..]] { + let doc = document(properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"), &[hidden]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }); + + assert!(result.is_err(), "unsafe state accepted: {properties}"); + } + } + } +} diff --git a/crates/engine/tests/support/optional_content_xref.rs b/crates/engine/tests/support/optional_content_xref.rs new file mode 100644 index 000000000..9730969e2 --- /dev/null +++ b/crates/engine/tests/support/optional_content_xref.rs @@ -0,0 +1,143 @@ +use super::support::{catalog, document, fixture, nameless_fixture, painted}; +use designcraft_compose::Cache; +use designcraft_doc::{AssetId, Content}; +use designcraft_pdf::{BookletKind, BookletOptions, PdfOptions, Standard, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs}; +use std::sync::Arc; + +fn broken_xref(bytes: Vec) -> Vec { + let text = String::from_utf8(bytes).unwrap(); + let (body, _) = text.rsplit_once("startxref").unwrap(); + let bytes = format!("{body}startxref\n1\n%%EOF\n").into_bytes(); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("hayro must repair the synthetic broken xref"); + assert_eq!(pdf.pages().len(), 1); + let root = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + assert!(root.get::>(b"OCProperties").is_some()); + bytes +} + +fn all_visible(standard: Standard) { + let source = broken_xref(fixture("/BaseState/ON", "Hidden")); + painted(&source, &[true, true]); + let doc = document(source, &[&[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + super::assert_vector(&report.bytes); + painted(&report.bytes, &[true, true]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } +} + +#[test] +fn plain_export_accepts_repaired_xref_when_all_layers_are_visible() { + all_visible(Standard::None); +} + +#[test] +fn pdfx_export_accepts_repaired_xref_when_all_layers_are_visible() { + all_visible(Standard::PdfX4); +} + +#[test] +fn booklet_accepts_repaired_xref_when_all_layers_are_visible() { + let doc = document(broken_xref(fixture("/BaseState/ON", "Hidden")), &[&[]]); + let options = BookletOptions { kind: BookletKind::TwoUpConsecutive, ..Default::default() }; + + let report = export_booklet(&doc, &Cache::new(), &options).unwrap(); + + assert!(report.warnings.is_empty()); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + super::assert_vector(&report.bytes); + painted(&report.bytes, &[true, true, false, false]); +} + +#[test] +fn book_merge_accepts_repaired_xref_when_all_layers_are_visible() { + let source = broken_xref(fixture("/BaseState/ON", "Hidden")); + + let bytes = merge_pdfs(&[(source, vec![(100.0, 100.0)])], None).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + super::assert_vector(&bytes); + painted(&bytes, &[true, true]); +} + +#[test] +fn repaired_xref_with_default_off_fails_with_named_damage_error() { + let doc = document(broken_xref(fixture("/BaseState/ON/OFF[6 3 R]", "Hidden")), &[&[]]); + + for standard in [Standard::None, Standard::PdfX4] { + let error = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap_err().to_string(); + assert!(error.contains("layers.pdf"), "{error}"); + assert!(error.contains("cross-reference table is damaged"), "{error}"); + assert!(!error.contains("optional-content state"), "{error}"); + } +} + +#[test] +fn repaired_xref_with_explicit_hidden_layer_fails_with_named_damage_error() { + let doc = document(broken_xref(fixture("/BaseState/ON", "Hidden")), &[&["Hidden"]]); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert!(error.contains("layers.pdf") && error.contains("cross-reference table is damaged"), "{error}"); +} + +#[test] +fn repaired_xref_needing_generated_name_fails_with_named_damage_error() { + let doc = document(broken_xref(nameless_fixture("/BaseState/ON")), &[&[]]); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert!(error.contains("layers.pdf") && error.contains("cross-reference table is damaged"), "{error}"); +} + +fn two_assets(first: Vec, second: Vec) -> designcraft_doc::Document { + let mut doc = document(first, &[&[], &[]]); + let original = doc.assets.values().next().unwrap().as_ref().clone(); + Arc::make_mut(doc.assets.values_mut().next().unwrap()).name = "first.pdf".into(); + let id = AssetId(doc.alloc()); + let mut other = original; + other.id = id; + other.name = "second.pdf".into(); + other.data = Arc::new(second); + doc.add_asset(other); + let spread = Arc::make_mut(doc.spreads.first_mut().unwrap()); + let item = Arc::make_mut(spread.items.last_mut().unwrap()); + let Content::Graphic(graphic) = &mut item.content else { panic!("fixture must have a graphic") }; + graphic.asset = id; + doc +} + +#[test] +fn error_identifies_unresolvable_pdf_among_two_placements() { + let doc = two_assets(fixture("/BaseState/ON", "Hidden"), fixture("/OFF[99 0 R]", "Hidden")); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert_eq!(error, "PDF writer error: second.pdf: the placed PDF's optional-content state could not be preserved safely"); +} + +#[test] +fn first_placement_error_is_not_overwritten() { + let doc = two_assets(fixture("/OFF[99 0 R]", "Hidden"), fixture("/OFF[98 0 R]", "Hidden")); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert!(error.contains("first.pdf"), "{error}"); + assert!(!error.contains("second.pdf"), "{error}"); +} + +#[test] +fn repeated_recoverable_variant_reports_one_warning_and_paints_each_placement() { + let doc = document(fixture("/BaseState/Unchanged", "Hidden"), &[&[], &[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(report.warnings.len(), 1); + assert!(report.warnings[0].contains("layers.pdf")); + painted(&report.bytes, &[true, true, true, true]); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 34934b911..61fea9ac2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1,5 +1,9 @@ //! Document → PDF. The item walk mirrors `designcraft-render`'s `draw_spread` / `draw_item`. +#[cfg(test)] +#[path = "registration_tests.rs"] +pub(crate) mod registration_tests; + use std::collections::HashMap; use std::sync::Arc; @@ -169,6 +173,11 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions #[cfg(test)] image_conversions: 0, pdfs: HashMap::new(), + variants: HashMap::new(), + has_optional_content: false, + #[cfg(test)] + pdf_preparations: 0, + layer_error: None, version, too_new: Vec::new(), svgs: HashMap::new(), @@ -227,6 +236,9 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions pdf.set_tag_tree(ex.tag_tree()); } ex.check_placed_versions()?; + if let Some(error) = ex.layer_error { + return Err(error); + } let mut bytes = writer.finish().map_err(PdfError::from)?; // Transparency blends in the document's blend space: CMYK for print (and always in PDF/X). let mut warnings = ex.warnings; @@ -235,6 +247,9 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions // group that got past it, with its page. crate::pdfx::cmyk_group_spaces(&mut bytes); } + if ex.has_optional_content { + bytes = crate::optional_content::register(&bytes)?; + } // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X // or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and // Screen annotations aren't allowed in PDF/A. @@ -277,8 +292,11 @@ fn merge_pdfs_inner(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) - let mut writer = Writer::new(krilla::SerializeSettings::default(), metadata); let pdf = writer.document_mut(); let mut pages = 0; + let mut has_optional_content = false; for (bytes, sizes) in parts { - let src = krilla::pdf::Pdf::new(Arc::new(bytes.clone())).map_err(|e| PdfError::Write(format!("{e:?}")))?; + let variant = crate::optional_content::prepare(&Arc::new(bytes.clone()), &[])?; + has_optional_content |= !sizes.is_empty() && variant.has_optional_content; + let src = krilla::pdf::Pdf::new(variant.data).map_err(|e| PdfError::Write(format!("{e:?}")))?; let doc = krilla::pdf::PdfDocument::new(Arc::new(src)); for (i, &(w, h)) in sizes.iter().enumerate() { let size = Size::from_wh(w.max(1.0), h.max(1.0)).ok_or(PdfError::NoPages)?; @@ -295,7 +313,8 @@ fn merge_pdfs_inner(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) - if pages == 0 { return Err(PdfError::NoPages); } - writer.finish().map_err(PdfError::from) + let bytes = writer.finish().map_err(PdfError::from)?; + if has_optional_content { crate::optional_content::register(&bytes) } else { Ok(bytes) } } /// File › Print Booklet: how pages pair up on printer spreads. @@ -356,6 +375,11 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> #[cfg(test)] image_conversions: 0, pdfs: HashMap::new(), + variants: HashMap::new(), + has_optional_content: false, + #[cfg(test)] + pdf_preparations: 0, + layer_error: None, // krilla's default. version: PdfVersion::Pdf17, too_new: Vec::new(), @@ -405,10 +429,16 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> page.finish(); } ex.check_placed_versions()?; + if let Some(error) = ex.layer_error { + return Err(error); + } let mut bytes = writer.finish().map_err(PdfError::from)?; if doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk { crate::pdfx::cmyk_group_spaces(&mut bytes); } + if ex.has_optional_content { + bytes = crate::optional_content::register(&bytes)?; + } let mut warnings = ex.warnings; warnings.dedup(); Ok(ExportReport { bytes, pages: pairs.len(), warnings }) @@ -487,7 +517,12 @@ pub(crate) struct Exporter<'a> { #[cfg(test)] image_conversions: usize, /// Placed PDFs (embedded as vector pages) and their page counts. - pdfs: HashMap>, + pdfs: HashMap<(AssetId, Vec), Option<(krilla::pdf::PdfDocument, usize)>>, + variants: HashMap<(AssetId, Vec), crate::optional_content::Variant>, + has_optional_content: bool, + #[cfg(test)] + pdf_preparations: usize, + layer_error: Option, /// The PDF version written; placed PDFs must not be newer. version: PdfVersion, /// Placed PDFs too new to embed in this version ("name is PDF 2.0"): the export fails. @@ -965,7 +1000,7 @@ impl Exporter<'_> { let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; if let Some(p) = &path { s.push_clip_path(p, &FillRule::NonZero); - self.graphic(s, g); + self.graphic(s, g, &it.pdf_hidden_layers); s.pop(); } } @@ -1258,9 +1293,9 @@ impl Exporter<'_> { /// `qpdf --force-version=1.6` does). A newer /// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]). /// Returns the document with its page count. - fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<(krilla::pdf::PdfDocument, usize)> { + fn placed_pdf(&mut self, asset: &designcraft_doc::Asset, data: Arc>) -> Option<(krilla::pdf::PdfDocument, usize)> { use hayro_syntax::PdfVersion as V; - let Ok(pdf) = krilla::pdf::Pdf::new(asset.data.clone()) else { + let Ok(pdf) = krilla::pdf::Pdf::new(data.clone()) else { self.warn(format!("{}: can't read the placed PDF", asset.name)); return None; }; @@ -1278,7 +1313,7 @@ impl Exporter<'_> { } if have == V::Pdf17 && max == V::Pdf16 - && let bytes = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| asset.data.to_vec()) + && let bytes = relabel_pdf_header(&data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| data.to_vec()) && let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(relabel_catalog_version(bytes))) && pdf.version() <= max { @@ -1372,24 +1407,24 @@ impl Exporter<'_> { } /// Draw a placed graphic, inside its clipping path when it has one. - pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { + pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: &[String]) { match g.clip_region().map(|(region, even_odd)| (to_path(®ion), even_odd)) { Some((Some(clip), even_odd)) => { s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero }); - self.graphic_unclipped(s, g); + self.graphic_unclipped(s, g, hidden); s.pop(); } // A degenerate outline shows nothing, or everything when inverted. Some((None, even_odd)) => { if even_odd { - self.graphic_unclipped(s, g); + self.graphic_unclipped(s, g, hidden); } } - None => self.graphic_unclipped(s, g), + None => self.graphic_unclipped(s, g, hidden), } } - fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { + fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: &[String]) { // Video / sound without a poster: a dark frame with a play mark. if let Some(asset) = self.doc.assets.get(&g.asset) && designcraft_doc::media_kind(&asset.mime).is_some() @@ -1423,11 +1458,39 @@ impl Exporter<'_> { let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t)); let Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.max(1e-3) as f32) else { return }; - let placed = match self.pdfs.get(&g.asset) { + let request = (g.asset, hidden.to_vec()); + let variant = match self.variants.get(&request) { + Some(variant) => variant.clone(), + None => { + #[cfg(test)] + { + self.pdf_preparations += 1; + } + match crate::optional_content::prepare(&asset.data, hidden) { + Ok(variant) => { + self.variants.insert(request, variant.clone()); + variant + } + Err(error) => { + let message = match error { + PdfError::Write(message) => message, + other => other.to_string(), + }; + self.layer_error.get_or_insert(PdfError::Write(format!("{}: {message}", asset.name))); + return; + } + } + } + }; + if let Some(warning) = variant.warning { + self.warn(format!("{}: {warning}", asset.name)); + } + let key = (g.asset, variant.hidden); + let placed = match self.pdfs.get(&key) { Some(p) => p.clone(), None => { - let p = self.placed_pdf(asset); - self.pdfs.insert(g.asset, p.clone()); + let p = self.placed_pdf(asset, variant.data); + self.pdfs.insert(key, p.clone()); p } }; @@ -1437,6 +1500,7 @@ impl Exporter<'_> { self.warn(format!("{}: the placed PDF has no page {}", asset.name, u64::from(asset.page) + 1)); } Some((placed, _)) => { + self.has_optional_content |= variant.has_optional_content; s.push_transform(&tf(g.xf)); let clip = shown.and_then(|_| to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1))); if let Some(clip) = &clip { @@ -1884,6 +1948,10 @@ mod image_content_cache_tests { images_by_content: ImageContentCache::default(), image_conversions: 0, pdfs: HashMap::new(), + variants: HashMap::new(), + has_optional_content: false, + pdf_preparations: 0, + layer_error: None, version: PdfVersion::Pdf17, too_new: Vec::new(), svgs: HashMap::new(), @@ -1898,6 +1966,42 @@ mod image_content_cache_tests { } } #[test] + fn prepared_pdf_variants_reuse_parsing_by_asset_and_hidden_names() { + let mut doc = Document::new(&Default::default()); + let cache = Cache::new(); + let opts = PdfOptions::default(); + let source = export_pdf(&doc, &cache, &opts).unwrap(); + let a = asset(&mut doc, source.clone(), "a.pdf"); + let b = asset(&mut doc, source, "b.pdf"); + let mut ex = exporter(&doc, &cache, &opts); + let mut writer = Writer::new(Default::default(), Default::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap())); + let mut surface = page.surface(); + for (id, hidden, count) in [(a, vec![], 1), (a, vec![], 1), (a, vec!["Hidden".into()], 2), (b, vec![], 3), (a, vec![], 3)] { + let graphic = designcraft_doc::Graphic { + asset: id, + size: (100.0, 100.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }; + ex.graphic_unclipped(&mut surface, &graphic, &hidden); + assert!(ex.layer_error.is_none()); + assert_eq!(ex.pdf_preparations, count, "count actual preparations, not writer resource deduplication"); + } + assert_eq!(ex.variants.len(), 3); + assert_eq!(ex.pdfs.len(), 2); + let variant = ex.variants.get(&(a, Vec::new())).unwrap(); + assert!(Arc::ptr_eq(&variant.data, &doc.assets[&a].data)); + surface.finish(); + page.finish(); + assert!(writer.finish().is_ok()); + } + #[test] fn exact_bytes_reuse_conversion_under_real_hash_collisions() { use std::hash::{BuildHasher, Hasher}; let mut d = Document::new(&Default::default()); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ad1123897..72e4028cd 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -35,6 +35,7 @@ mod marks; mod text; mod forms; +mod optional_content; mod pdfx; mod transitions; pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, sheet_spreads}; diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs new file mode 100644 index 000000000..8bd795937 --- /dev/null +++ b/crates/pdf/src/optional_content.rs @@ -0,0 +1,279 @@ +use hayro_syntax::Pdf; +use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream}; +use hayro_syntax::reader::{Reader, ReaderExt}; +use std::sync::Arc; + +use crate::{PdfError, Result}; + +const MARKER: &[u8] = b"DesignCraftHiddenOCG"; + +fn invalid() -> PdfError { + PdfError::Write("the placed PDF's optional-content state could not be preserved safely".into()) +} + +fn references(dict: &Dict<'_>, key: &[u8]) -> Result> { + if !dict.contains_key(key) { + return Ok(Vec::new()); + } + dict.get::>(key).ok_or_else(invalid)?.raw_iter().map(|value| value.as_obj_ref().map(Into::into).ok_or_else(invalid)).collect() +} + +fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result> { + let data = dict.data(); + let body = data.strip_prefix(b"<<").and_then(|d| d.strip_suffix(b">>")).ok_or_else(invalid)?; + let mut reader = Reader::new(body); + let mut out = b"<<".to_vec(); + loop { + reader.skip_white_spaces_and_comments(); + if reader.at_end() { + break; + } + let start = reader.offset(); + let name = reader.read_without_context::>().ok_or_else(invalid)?; + reader.skip_white_spaces_and_comments(); + reader.skip::>>(false).ok_or_else(invalid)?; + if !replacements.iter().any(|(key, _)| *key == name.as_ref()) { + out.push(b'\n'); + out.extend_from_slice(body.get(start..reader.offset()).ok_or_else(invalid)?); + } + } + for (key, value) in replacements { + if !value.is_empty() { + out.extend_from_slice(b"\n/"); + out.extend_from_slice(key); + out.push(b' '); + out.extend_from_slice(value); + } + } + out.extend_from_slice(b"\n>>"); + Ok(out) +} + +fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIdentifier) -> Result> { + let damaged = || PdfError::Write("the placed PDF's cross-reference table is damaged; layer changes could not be preserved safely".into()); + let at = crate::transitions::rfind(bytes, b"startxref").ok_or_else(damaged)?; + let tail = std::str::from_utf8(bytes.get(at..).ok_or_else(damaged)?).map_err(|_| damaged())?; + let prev = crate::transitions::int_after(tail, "startxref").ok_or_else(damaged)?; + let section = bytes.get(prev..).ok_or_else(damaged)?; + let trailer = if section.starts_with(b"xref") { + let at = crate::transitions::find(section, b"trailer", 0).ok_or_else(damaged)?; + let mut reader = Reader::new(section.get(at + 7..).ok_or_else(damaged)?); + reader.skip_white_spaces_and_comments(); + reader.read_without_context::>().ok_or_else(damaged)? + } else { + let mut reader = Reader::new(section); + reader.read_without_context::().ok_or_else(damaged)?; + reader.skip_white_spaces_and_comments(); + let stream = reader.read_without_context::>().ok_or_else(damaged)?; + if stream.dict().get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"XRef") { + return Err(damaged()); + } + stream.dict().clone() + }; + if trailer.contains_key(b"Encrypt") { + return Err(invalid()); + } + trailer.get::(b"Size").filter(|n| *n > 0).ok_or_else(damaged)?; + let mut out = bytes.to_vec(); + out.push(b'\n'); + let mut offsets = Vec::new(); + for (id, body) in changes { + offsets.push((*id, out.len())); + out.extend_from_slice(format!("{} {} obj\n", id.obj_number, id.gen_number).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(b"xref\n"); + offsets.sort_by_key(|(id, _)| *id); + for (id, offset) in offsets { + out.extend_from_slice(format!("{} 1\n{offset:010} {:05} n\r\n", id.obj_number, id.gen_number).as_bytes()); + } + let root = format!("{} {} R", root.obj_number, root.gen_number); + let prev = prev.to_string(); + let trailer = edited( + &trailer, + &[ + (b"Root", root.as_bytes()), + (b"Prev", prev.as_bytes()), + (b"XRefStm", b""), + (b"Type", b""), + (b"W", b""), + (b"Index", b""), + (b"Length", b""), + (b"Filter", b""), + (b"DecodeParms", b""), + ], + )?; + out.extend_from_slice(b"trailer\n"); + out.extend_from_slice(&trailer); + out.extend_from_slice(format!("\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Ok(out) +} + +#[derive(Clone)] +pub(crate) struct Variant { + pub data: Arc>, + pub hidden: Vec, + pub warning: Option, + pub has_optional_content: bool, +} + +pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result { + let pdf = Pdf::new(bytes.clone()).map_err(|_| invalid())?; + let xref = pdf.xref(); + let root = xref.get::>(xref.root_id()).ok_or_else(invalid)?; + if !root.contains_key(b"OCProperties") { + let has_optional_content = pdf + .objects() + .into_iter() + .any(|object| matches!(object, Object::Dict(dict) if dict.get::>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"OCG"))); + return Ok(Variant { data: bytes.clone(), hidden: Vec::new(), warning: None, has_optional_content }); + } + let properties = root.get::>(b"OCProperties").ok_or_else(invalid)?; + let mut groups = references(&properties, b"OCGs")?; + let config = properties.get::>(b"D"); + let mut fallback = config.is_none(); + let config = config.unwrap_or_default(); + let base_off = match config.get::>(b"BaseState").as_ref().map(AsRef::as_ref) { + None if !config.contains_key(b"BaseState") => false, + Some(b"ON") => false, + Some(b"OFF") => true, + None | Some(_) => { + fallback = true; + false + } + }; + let off = references(&config, b"OFF")?; + let on = match references(&config, b"ON") { + Ok(on) => on, + Err(error) if !fallback && !base_off && off.is_empty() => { + let value = config.get::>(b"ON").ok_or(error)?; + fallback = true; + value.into_array().map(|array| array.raw_iter().filter_map(|value| value.as_obj_ref().map(Into::into)).collect()).unwrap_or_default() + } + Err(error) => return Err(error), + }; + if on.iter().any(|id| off.contains(id)) { + return Err(invalid()); + } + let incomplete = on.iter().chain(&off).any(|id| !groups.contains(id)); + groups.extend(on.iter().chain(&off).copied()); + groups.sort(); + groups.dedup(); + // Aggregating applications use the default state, not /AS usage-event adjustments. + let mut changes = Vec::new(); + let mut effective = Vec::new(); + let mut renamed = false; + for id in groups { + let group = xref.get::>(id).ok_or_else(invalid)?; + if group.get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") { + return Err(invalid()); + } + let name = group.get::>(b"Name"); + let is_hidden = match (hidden.is_empty(), name.as_ref()) { + (true, _) => !fallback && (off.contains(&id) || (base_off && !on.contains(&id))), + (false, Some(name)) => { + let name = String::from_utf8_lossy(name.as_bytes()); + hidden.iter().any(|n| n == name.as_ref()) + } + (false, None) => return Err(invalid()), + }; + if is_hidden { + effective.push(id); + } + // Unmarked groups are visible when registered in the output catalog. + if !is_hidden && name.is_some() { + continue; + } + let marker = is_hidden.to_string(); + let body = match name { + Some(_) => edited(&group, &[(MARKER, marker.as_bytes())])?, + None => { + if group.contains_key(b"Name") && group.get::>(b"Name").is_none() { + return Err(invalid()); + } + renamed = true; + let label = format!("(Placed PDF layer {} {})", id.obj_number, id.gen_number); + edited(&group, &[(MARKER, marker.as_bytes()), (b"Name", label.as_bytes())])? + } + }; + changes.push((id, body)); + } + effective.sort(); + effective.dedup(); + let data = if changes.is_empty() { bytes.clone() } else { Arc::new(append(bytes, &changes, xref.root_id())?) }; + let mut warnings = Vec::new(); + if fallback { + warnings.push("layer state not readable, all layers shown unless hidden by Object Layer Options"); + } + if incomplete { + warnings.push("layer list incomplete; layers named in the default configuration were added"); + } + if renamed { + warnings.push("layer display name not readable; generated a name, visibility preserved"); + } + let warning = if warnings.is_empty() { None } else { Some(warnings.join("; ")) }; + Ok(Variant { data, hidden: effective, warning, has_optional_content: true }) +} + +pub(crate) fn register(bytes: &[u8]) -> Result> { + #[cfg(test)] + crate::export::registration_tests::REGISTRATIONS.with(|count| count.set(count.get() + 1)); + let pdf = Pdf::new(bytes.to_vec()).map_err(|_| invalid())?; + let mut groups = Vec::new(); + let mut changes = Vec::new(); + for object in pdf.objects() { + let Object::Dict(dict) = object else { continue }; + if dict.get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") { + continue; + } + // Resource-only OCGs without a catalog configuration are visible by default. + let hidden = if dict.contains_key(MARKER) { dict.get::(MARKER).ok_or_else(invalid)? } else { false }; + let id = dict.obj_id().ok_or_else(invalid)?; + groups.push((id, hidden)); + changes.push((id, edited(&dict, &[(MARKER, b"")])?)); + } + if groups.is_empty() { + return Ok(bytes.to_vec()); + } + groups.sort_by_key(|(id, _)| *id); + let refs = |hidden_only: bool| { + groups + .iter() + .filter(|(_, hidden)| !hidden_only || *hidden) + .map(|(id, _)| format!("{} {} R", id.obj_number, id.gen_number)) + .collect::>() + .join(" ") + }; + let all = refs(false); + let properties = format!("<>>>", refs(true)); + let root = pdf.xref().root_id(); + let catalog = pdf.xref().get::>(root).ok_or_else(invalid)?; + changes.push((root, edited(&catalog, &[(b"OCProperties", properties.as_bytes())])?)); + append(bytes, &changes, root) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn visible_named_groups_keep_original_buffer_with_repaired_xref() { + let bytes = Arc::new( + b"%PDF-1.5\n1 0 obj\n<>>>>>\nendobj\n\ + 2 0 obj\n<>\nendobj\n\ + 3 0 obj\n<>>>\nendobj\n\ + 4 0 obj\n<>\nendobj\n\ + trailer\n<>\nstartxref\n1\n%%EOF\n" + .to_vec(), + ); + assert!(Pdf::new(bytes.clone()).is_ok()); + + let variant = prepare(&bytes, &[]).unwrap(); + + assert!(Arc::ptr_eq(&bytes, &variant.data), "visible groups must not copy the complete PDF or require incremental writes"); + assert!(variant.hidden.is_empty()); + assert!(variant.warning.is_none()); + } +} diff --git a/crates/pdf/src/registration_tests.rs b/crates/pdf/src/registration_tests.rs new file mode 100644 index 000000000..e69d87051 --- /dev/null +++ b/crates/pdf/src/registration_tests.rs @@ -0,0 +1,204 @@ +use super::*; + +fn placed_document(source: Vec) -> Document { + use designcraft_doc::{Asset, Graphic, ItemId, Shape}; + let mut doc = Document::new(&Default::default()); + let asset = AssetId(doc.alloc()); + doc.add_asset(Asset { id: asset, name: "placed.pdf".into(), mime: "application/pdf".into(), data: Arc::new(source), ..Default::default() }); + let mut item = + Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + item.content = Content::Graphic(Graphic { + asset, + size: (100.0, 100.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + doc +} + +fn layered_source(catalog: bool) -> Vec { + let properties = if catalog { "/OCProperties<>>>" } else { "" }; + let content = b"/OC /a BDC 0.125 0.625 0.875 rg 0 0 100 100 re f EMC"; + let objects = [ + format!("<>"), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{}\nendstream", content.len(), String::from_utf8_lossy(content)), + "<>".into(), + ]; + assemble(&objects) +} + +fn unlayered_source() -> Vec { + let content = "0.125 0.625 0.875 rg 0 0 100 100 re f"; + assemble(&[ + "<>".into(), + "<>".into(), + "<>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ]) +} + +fn assemble(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for (index, body) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", index + 1).as_bytes()); + } + let xref = bytes.len(); + let size = objects.len() + 1; + bytes.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f\r\n").as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes +} + +thread_local! { + pub(crate) static REGISTRATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn registrations(action: impl FnOnce()) -> usize { + REGISTRATIONS.set(0); + action(); + REGISTRATIONS.get() +} + +fn check_vector_artwork(pdf: &hayro_syntax::Pdf) { + use hayro_syntax::object::{Name, Object}; + + let mut found = false; + for object in pdf.objects() { + let Object::Stream(stream) = object else { continue }; + let subtype = stream.dict().get::>(b"Subtype"); + assert_ne!(subtype.as_ref().map(AsRef::as_ref), Some(b"Image".as_slice()), "placed artwork must remain vector"); + if subtype.as_ref().map(AsRef::as_ref) == Some(b"Form".as_slice()) { + let content = stream.decoded().unwrap(); + let content = String::from_utf8_lossy(&content); + found |= content.contains("0.125 0.625 0.875 rg") && content.contains("0 0 100 100 re f"); + } + } + assert!(found, "placed rectangle and its distinctive fill colour must survive in a vector Form XObject"); +} + +fn check_unlayered_pdf(bytes: &[u8], pages: usize, artwork: bool) { + use hayro_syntax::object::Dict; + + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + assert_eq!(pdf.pages().len(), pages); + let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + assert!(!catalog.contains_key(b"OCProperties"), "unlayered output must not acquire optional content"); + if artwork { + check_vector_artwork(&pdf); + } +} + +fn check_unlayered(doc: &Document, artwork: bool) { + let cache = Cache::new(); + let mut bytes = Vec::new(); + assert_eq!( + registrations(|| { + bytes = export_pdf(doc, &cache, &PdfOptions::default()).unwrap(); + check_unlayered_pdf(&bytes, 1, artwork); + }), + 0 + ); + assert_eq!( + registrations(|| { + let report = export_booklet(doc, &cache, &BookletOptions::default()).unwrap(); + check_unlayered_pdf(&report.bytes, 2, artwork); + }), + 0 + ); + assert_eq!( + registrations(|| { + let merged = merge_pdfs(&[(bytes, vec![(100.0, 100.0)])], None).unwrap(); + check_unlayered_pdf(&merged, 1, artwork); + }), + 0 + ); +} + +#[test] +fn no_placed_pdfs_skip_optional_content_registration() { + check_unlayered(&Document::new(&Default::default()), false); +} + +#[test] +fn unlayered_placed_pdf_skips_optional_content_registration() { + check_unlayered(&placed_document(unlayered_source()), true); +} + +#[test] +fn layered_and_resource_only_inputs_register_optional_content() { + for hidden in [true, false] { + let source = layered_source(hidden); + let doc = placed_document(source.clone()); + let cache = Cache::new(); + let mut exported = Vec::new(); + assert_eq!( + registrations(|| { + exported = export_pdf(&doc, &cache, &PdfOptions::default()).unwrap(); + }), + 1 + ); + check_registered(&exported, hidden); + assert_eq!( + registrations(|| { + let report = export_booklet(&doc, &cache, &BookletOptions::default()).unwrap(); + check_registered(&report.bytes, hidden); + }), + 1 + ); + for input in [source, exported] { + assert_eq!( + registrations(|| { + let merged = merge_pdfs(&[(input, vec![(100.0, 100.0)])], None).unwrap(); + check_registered(&merged, hidden); + }), + 1 + ); + } + } +} + +fn check_registered(bytes: &[u8], hidden: bool) { + use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier}; + + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + check_vector_artwork(&pdf); + let xref = pdf.xref(); + let catalog = xref.get::>(xref.root_id()).unwrap(); + let properties = catalog.get::>(b"OCProperties").unwrap(); + let groups = properties.get::>(b"OCGs").unwrap(); + assert_eq!(groups.raw_iter().count(), 1); + let group_id: ObjectIdentifier = groups.raw_iter().next().unwrap().as_obj_ref().unwrap().into(); + let group = xref.get::>(group_id).unwrap(); + assert_eq!(group.get::>(b"Type").unwrap().as_ref(), b"OCG"); + assert_eq!(group.get::>(b"Name").unwrap().as_bytes(), b"Hidden"); + + let config = properties.get::>(b"D").unwrap(); + if hidden { + let off = config.get::>(b"OFF").unwrap(); + assert_eq!(off.raw_iter().count(), 1); + let off_id: ObjectIdentifier = off.raw_iter().next().unwrap().as_obj_ref().unwrap().into(); + assert_eq!(off_id, group_id, "OFF must reference the Hidden OCG listed in OCGs"); + } else if config.contains_key(b"OFF") { + assert_eq!(config.get::>(b"OFF").unwrap().raw_iter().count(), 0); + } + // Superseded revisions may retain the marker; readers use only the objects resolved by the current xref. + for object in pdf.objects() { + let Object::Dict(dict) = object else { continue }; + if dict.get::>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"OCG") { + assert!(!dict.contains_key(b"DesignCraftHiddenOCG"), "internal marker remains in resolved OCG {:?}", dict.obj_id()); + } + } +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 1aaf435d9..655669c6a 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -318,7 +318,7 @@ impl Exporter<'_> { if let Some(clip) = to_path(&c.clip.to_path(0.1)) { s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0)))); - self.graphic(s, g); + self.graphic(s, g, &[]); s.pop(); s.pop(); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8ae902a21..9e8f70e8c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -394,7 +394,8 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document and in IDML's spread transform; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. - Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. -- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. +- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen. Plain and PDF/X-4 exports retain vector artwork and optional content with the source defaults or per-frame overrides, including independent states for repeated placements. Missing or unreadable default configurations and unsupported base states fall back to all-ON defaults with a file-named warning, preserving explicit frame overrides. Readable groups omitted from `/OCGs` are added with a distinct file-named warning, preserving their default ON/OFF states; concurrent recovery warnings are combined. Malformed ON data is recoverable only when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides remain errors. PDF/A-2b command exports retain the existing 300 ppi raster path for explicit overrides; PDF/A-2 permits optional content, but this change does not extend archival validation. IDML GraphicLayer visibility import remains unsupported. +- Placed-PDF export preparation is cached per asset and hidden-name set; repeated placements retain recovery warnings (identical warnings are deduplicated). When all groups are visible and named, the original source bytes are embedded without an incremental layer update, including sources whose broken cross-reference tables the reader repairs. Plain, PDF/X-4, booklet and book-merge output register those unmarked groups as visible. Hidden groups or generated names still require a usable cross-reference table; damage errors identify the placed file, and export retains the first placement error rather than replacing it with a later failure. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). - Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. - View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups). From a84faee829c9e414bc2aa3afc181a9baa6fcd9be Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:45:00 -0600 Subject: [PATCH 2/6] PDF: a hidden layer missing from the placed file aborts vector export Main now aborts an export when a stored hidden layer can no longer be applied (#876). Vector export follows it: a layer hidden in Object Layer Options that the placed PDF doesn't have, because the file has no layers or was relinked, fails with an error naming the file instead of exporting everything visible. The image-path tests for oversized images and undecodable content export as PDF/A-2b, which still rasterizes placed PDFs with hidden layers. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/it/pdf_backend.rs | 12 +++++------- crates/pdf/src/export.rs | 13 +++++++++++-- crates/pdf/src/optional_content.rs | 21 ++++++++++++++++++++- crates/pdf/src/registration_tests.rs | 2 +- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/crates/engine/tests/it/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs index 5344dc6f7..e68cbae81 100644 --- a/crates/engine/tests/it/pdf_backend.rs +++ b/crates/engine/tests/it/pdf_backend.rs @@ -118,11 +118,7 @@ fn exported(hidden: bool) -> Vec { session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap(); } let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap(); - let bytes = base64_decode(report["base64"].as_str().unwrap()); - if hidden { - assert!(report["warnings"].to_string().contains("hidden layers")); - } - bytes + base64_decode(report["base64"].as_str().unwrap()) } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] @@ -185,7 +181,8 @@ fn oversized_image_does_not_silently_disappear_from_layered_export() { .unwrap(); session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); let before = session.execute("file.serialize", &json!({})).unwrap(); - let result = session.execute("file.exportPdf", &json!({})); + // PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there. + let result = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"})); assert!(result.is_err(), "export must report the image omitted by the backend's resource limit"); assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "a failed export must leave the document unchanged"); let error = result.unwrap_err().to_string(); @@ -317,7 +314,8 @@ fn failed_content_decode_aborts_layered_export_without_changing_the_document() { .unwrap(); session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); let before = session.execute("file.serialize", &json!({})).unwrap(); - let error = session.execute("file.exportPdf", &json!({})).unwrap_err().to_string(); + // PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there. + let error = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"})).unwrap_err().to_string(); assert!(error.contains("broken-content.pdf") && error.contains("content"), "{filter}: {error}"); assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "{filter}"); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 61fea9ac2..db7c77ae4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1970,7 +1970,15 @@ mod image_content_cache_tests { let mut doc = Document::new(&Default::default()); let cache = Cache::new(); let opts = PdfOptions::default(); - let source = export_pdf(&doc, &cache, &opts).unwrap(); + // One layer, "Hidden", shown by default: hiding it needs a prepared copy, showing it doesn't. + let content = "/OC /a BDC 0 0 1 rg 0 0 100 100 re f EMC"; + let source = registration_tests::assemble(&[ + "<>>>".into(), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>".into(), + ]); let a = asset(&mut doc, source.clone(), "a.pdf"); let b = asset(&mut doc, source, "b.pdf"); let mut ex = exporter(&doc, &cache, &opts); @@ -1994,7 +2002,8 @@ mod image_content_cache_tests { assert_eq!(ex.pdf_preparations, count, "count actual preparations, not writer resource deduplication"); } assert_eq!(ex.variants.len(), 3); - assert_eq!(ex.pdfs.len(), 2); + // a, b, and a with its layer hidden (a copy of its own). + assert_eq!(ex.pdfs.len(), 3); let variant = ex.variants.get(&(a, Vec::new())).unwrap(); assert!(Arc::ptr_eq(&variant.data, &doc.assets[&a].data)); surface.finish(); diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs index 8bd795937..6214f043f 100644 --- a/crates/pdf/src/optional_content.rs +++ b/crates/pdf/src/optional_content.rs @@ -11,6 +11,12 @@ fn invalid() -> PdfError { PdfError::Write("the placed PDF's optional-content state could not be preserved safely".into()) } +/// A layer hidden in Object Layer Options that the placed file doesn't have (relinked, say): +/// exporting would be a guess at what was meant to stay hidden. +fn missing_layer() -> PdfError { + PdfError::Write("a layer hidden in Object Layer Options is not in the placed PDF; relink it or reset its layer options".into()) +} + fn references(dict: &Dict<'_>, key: &[u8]) -> Result> { if !dict.contains_key(key) { return Ok(Vec::new()); @@ -128,6 +134,9 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"OCG"))); + if !hidden.is_empty() { + return Err(missing_layer()); + } return Ok(Variant { data: bytes.clone(), hidden: Vec::new(), warning: None, has_optional_content }); } let properties = root.get::>(b"OCProperties").ok_or_else(invalid)?; @@ -165,6 +174,7 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result>(id).ok_or_else(invalid)?; if group.get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") { @@ -175,7 +185,13 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result !fallback && (off.contains(&id) || (base_off && !on.contains(&id))), (false, Some(name)) => { let name = String::from_utf8_lossy(name.as_bytes()); - hidden.iter().any(|n| n == name.as_ref()) + let mut is_hidden = false; + for (n, found) in hidden.iter().zip(&mut found) { + if n == name.as_ref() { + (*found, is_hidden) = (true, true); + } + } + is_hidden } (false, None) => return Err(invalid()), }; @@ -200,6 +216,9 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result Vec { ]) } -fn assemble(objects: &[String]) -> Vec { +pub(super) fn assemble(objects: &[String]) -> Vec { let mut bytes = b"%PDF-1.5\n".to_vec(); let mut offsets = Vec::new(); for (index, body) in objects.iter().enumerate() { From ea698361468c85f54a95b9c9b5f7395fe9548d96 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:33:22 -0600 Subject: [PATCH 3/6] PDF: carry an explicit layer choice and read trailers structurally Export takes the placed PDF's layer choice as file defaults or an explicit hidden list, where an explicit empty list shows every layer, including one the file hides by default. Today an empty list still means file defaults; an explicit all-on state can map straight onto Some(&[]). The incremental writer now reads each cross-reference section's trailer by walking the table rather than searching for the keyword, follows the bounded Prev chain for inherited encryption, and treats a literal null /Encrypt as absent. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/export.rs | 21 ++-- crates/pdf/src/optional_content.rs | 191 +++++++++++++++++++++++++---- crates/pdf/src/text.rs | 2 +- 3 files changed, 181 insertions(+), 33 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index db7c77ae4..193491324 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -294,7 +294,7 @@ fn merge_pdfs_inner(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) - let mut pages = 0; let mut has_optional_content = false; for (bytes, sizes) in parts { - let variant = crate::optional_content::prepare(&Arc::new(bytes.clone()), &[])?; + let variant = crate::optional_content::prepare(&Arc::new(bytes.clone()), None)?; has_optional_content |= !sizes.is_empty() && variant.has_optional_content; let src = krilla::pdf::Pdf::new(variant.data).map_err(|e| PdfError::Write(format!("{e:?}")))?; let doc = krilla::pdf::PdfDocument::new(Arc::new(src)); @@ -518,7 +518,7 @@ pub(crate) struct Exporter<'a> { image_conversions: usize, /// Placed PDFs (embedded as vector pages) and their page counts. pdfs: HashMap<(AssetId, Vec), Option<(krilla::pdf::PdfDocument, usize)>>, - variants: HashMap<(AssetId, Vec), crate::optional_content::Variant>, + variants: HashMap<(AssetId, Option>), crate::optional_content::Variant>, has_optional_content: bool, #[cfg(test)] pdf_preparations: usize, @@ -1000,7 +1000,7 @@ impl Exporter<'_> { let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; if let Some(p) = &path { s.push_clip_path(p, &FillRule::NonZero); - self.graphic(s, g, &it.pdf_hidden_layers); + self.graphic(s, g, (!it.pdf_hidden_layers.is_empty()).then_some(it.pdf_hidden_layers.as_slice())); s.pop(); } } @@ -1406,8 +1406,9 @@ impl Exporter<'_> { img } - /// Draw a placed graphic, inside its clipping path when it has one. - pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: &[String]) { + /// Draw a placed graphic, inside its clipping path when it has one. `hidden` is the + /// placed PDF's layer choice: `None` keeps the file's defaults, `Some(&[])` shows every layer. + pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) { match g.clip_region().map(|(region, even_odd)| (to_path(®ion), even_odd)) { Some((Some(clip), even_odd)) => { s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero }); @@ -1424,7 +1425,7 @@ impl Exporter<'_> { } } - fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: &[String]) { + fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) { // Video / sound without a poster: a dark frame with a play mark. if let Some(asset) = self.doc.assets.get(&g.asset) && designcraft_doc::media_kind(&asset.mime).is_some() @@ -1458,7 +1459,7 @@ impl Exporter<'_> { let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t)); let Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.max(1e-3) as f32) else { return }; - let request = (g.asset, hidden.to_vec()); + let request = (g.asset, hidden.map(<[String]>::to_vec)); let variant = match self.variants.get(&request) { Some(variant) => variant.clone(), None => { @@ -1985,7 +1986,7 @@ mod image_content_cache_tests { let mut writer = Writer::new(Default::default(), Default::default()); let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap())); let mut surface = page.surface(); - for (id, hidden, count) in [(a, vec![], 1), (a, vec![], 1), (a, vec!["Hidden".into()], 2), (b, vec![], 3), (a, vec![], 3)] { + for (id, hidden, count) in [(a, None, 1), (a, None, 1), (a, Some(vec!["Hidden".into()]), 2), (b, None, 3), (a, None, 3)] { let graphic = designcraft_doc::Graphic { asset: id, size: (100.0, 100.0), @@ -1997,14 +1998,14 @@ mod image_content_cache_tests { wrap: Default::default(), fill: Default::default(), }; - ex.graphic_unclipped(&mut surface, &graphic, &hidden); + ex.graphic_unclipped(&mut surface, &graphic, hidden.as_deref()); assert!(ex.layer_error.is_none()); assert_eq!(ex.pdf_preparations, count, "count actual preparations, not writer resource deduplication"); } assert_eq!(ex.variants.len(), 3); // a, b, and a with its layer hidden (a copy of its own). assert_eq!(ex.pdfs.len(), 3); - let variant = ex.variants.get(&(a, Vec::new())).unwrap(); + let variant = ex.variants.get(&(a, None)).unwrap(); assert!(Arc::ptr_eq(&variant.data, &doc.assets[&a].data)); surface.finish(); page.finish(); diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs index 6214f043f..0dd0c4879 100644 --- a/crates/pdf/src/optional_content.rs +++ b/crates/pdf/src/optional_content.rs @@ -55,29 +55,77 @@ fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result> { Ok(out) } +/// The trailer of the cross-reference section at `offset`: a table's `trailer` dictionary or an +/// xref stream's own. A table is read entry by entry rather than searched for the keyword, so a +/// `trailer` in a comment can't stand in for the real one; reading is bounded either way. +fn trailer_at(bytes: &[u8], offset: usize) -> Option> { + const MAX_SECTION_BYTES: usize = 4 * 1024 * 1024; + let data = bytes.get(offset..)?; + let mut reader = Reader::new(data.get(..data.len().min(MAX_SECTION_BYTES))?); + reader.skip_white_spaces_and_comments(); + if reader.forward_tag(b"xref").is_none() { + reader.read_without_context::()?; + reader.skip_white_spaces_and_comments(); + let stream = reader.read_without_context::>()?; + let is_xref = stream.dict().get::>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"XRef"); + return is_xref.then(|| stream.dict().clone()); + } + // Every pass consumes input or fails, and the input is bounded, so this ends. + loop { + reader.skip_white_spaces(); + if reader.forward_tag(b"trailer").is_some() { + break; + } + reader.read_without_context::()?; + reader.skip_white_spaces(); + let count = reader.read_without_context::()?; + if count > MAX_SECTION_BYTES / 18 { + return None; + } + for _ in 0..count { + reader.skip_white_spaces(); + reader.read_without_context::()?; + reader.skip_white_spaces(); + if reader.read_without_context::()? > 65535 { + return None; + } + reader.skip_white_spaces(); + reader.forward_tag(b"n").or_else(|| reader.forward_tag(b"f"))?; + } + } + reader.skip_white_spaces_and_comments(); + reader.read_without_context::>() +} + +/// Whether `dict` has `key`: a literal `null` value is the same as no entry. A reference counts, +/// since a trailer has no context to resolve it in. +fn has_entry(dict: &Dict<'_>, key: &[u8]) -> bool { + dict.contains_key(key) && !matches!(dict.get_raw::>(key), Some(MaybeRef::NotRef(Object::Null(_)))) +} + fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIdentifier) -> Result> { let damaged = || PdfError::Write("the placed PDF's cross-reference table is damaged; layer changes could not be preserved safely".into()); let at = crate::transitions::rfind(bytes, b"startxref").ok_or_else(damaged)?; let tail = std::str::from_utf8(bytes.get(at..).ok_or_else(damaged)?).map_err(|_| damaged())?; let prev = crate::transitions::int_after(tail, "startxref").ok_or_else(damaged)?; - let section = bytes.get(prev..).ok_or_else(damaged)?; - let trailer = if section.starts_with(b"xref") { - let at = crate::transitions::find(section, b"trailer", 0).ok_or_else(damaged)?; - let mut reader = Reader::new(section.get(at + 7..).ok_or_else(damaged)?); - reader.skip_white_spaces_and_comments(); - reader.read_without_context::>().ok_or_else(damaged)? - } else { - let mut reader = Reader::new(section); - reader.read_without_context::().ok_or_else(damaged)?; - reader.skip_white_spaces_and_comments(); - let stream = reader.read_without_context::>().ok_or_else(damaged)?; - if stream.dict().get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"XRef") { + let trailer = trailer_at(bytes, prev).ok_or_else(damaged)?; + // Never write decrypted objects into an encrypted file. An earlier update's trailer can + // carry the encryption, so follow the (bounded, acyclic) Prev chain to its end. + let mut offset = Some(prev); + let mut visited = std::collections::HashSet::new(); + for _ in 0..32 { + let Some(at) = offset else { break }; + if !visited.insert(at) { return Err(damaged()); } - stream.dict().clone() - }; - if trailer.contains_key(b"Encrypt") { - return Err(invalid()); + let section = trailer_at(bytes, at).ok_or_else(damaged)?; + if has_entry(§ion, b"Encrypt") { + return Err(invalid()); + } + offset = section.get::(b"Prev"); + } + if offset.is_some() { + return Err(damaged()); } trailer.get::(b"Size").filter(|n| *n > 0).ok_or_else(damaged)?; let mut out = bytes.to_vec(); @@ -125,7 +173,10 @@ pub(crate) struct Variant { pub has_optional_content: bool, } -pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result { +/// The placed PDF with its layer choice applied: `None` keeps the file's own default +/// visibility, `Some(names)` shows every layer except `names` (so `Some(&[])` is all on). +pub(crate) fn prepare(bytes: &Arc>, choice: Option<&[String]>) -> Result { + let hidden = choice.unwrap_or_default(); let pdf = Pdf::new(bytes.clone()).map_err(|_| invalid())?; let xref = pdf.xref(); let root = xref.get::>(xref.root_id()).ok_or_else(invalid)?; @@ -181,9 +232,10 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result>(b"Name"); - let is_hidden = match (hidden.is_empty(), name.as_ref()) { - (true, _) => !fallback && (off.contains(&id) || (base_off && !on.contains(&id))), - (false, Some(name)) => { + let is_hidden = match (choice, name.as_ref()) { + (None, _) => !fallback && (off.contains(&id) || (base_off && !on.contains(&id))), + (Some([]), None) => false, + (Some(_), Some(name)) => { let name = String::from_utf8_lossy(name.as_bytes()); let mut is_hidden = false; for (n, found) in hidden.iter().zip(&mut found) { @@ -193,7 +245,7 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result return Err(invalid()), + (Some(_), None) => return Err(invalid()), }; if is_hidden { effective.push(id); @@ -289,10 +341,105 @@ mod tests { ); assert!(Pdf::new(bytes.clone()).is_ok()); - let variant = prepare(&bytes, &[]).unwrap(); + let variant = prepare(&bytes, None).unwrap(); assert!(Arc::ptr_eq(&bytes, &variant.data), "visible groups must not copy the complete PDF or require incremental writes"); assert!(variant.hidden.is_empty()); assert!(variant.warning.is_none()); } + + /// One page with layer "Off", hidden by the file's default configuration. `trailer` is the + /// trailer dictionary; `previous` an earlier revision's trailer, chained by /Prev. + fn default_hidden(trailer: &str, previous: Option<&str>) -> Arc> { + let mut bytes = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for body in [ + "<>>>>>", + "<>", + "<>>>", + "<>", + ] { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let table = |bytes: &mut Vec, trailer: &str| { + let at = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for offset in &offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n{trailer}\n").as_bytes()); + at + }; + let trailer = match previous { + Some(previous) => { + let at = table(&mut bytes, previous); + trailer.replace(">>", &format!("/Prev {at}>>")) + } + None => trailer.to_owned(), + }; + let at = table(&mut bytes, &trailer); + bytes.extend_from_slice(format!("startxref\n{at}\n%%EOF\n").as_bytes()); + Arc::new(bytes) + } + + #[test] + fn an_explicit_choice_overrides_a_layer_the_file_hides() { + let bytes = default_hidden("<>", None); + + let defaults = prepare(&bytes, None).unwrap(); + let all_on = prepare(&bytes, Some(&[])).unwrap(); + + let group = ObjectIdentifier::new(4, 0); + assert_eq!(defaults.hidden, vec![group], "file defaults keep the layer hidden"); + assert!(all_on.hidden.is_empty(), "an explicit all-on choice shows it"); + let registered = register(&all_on.data).unwrap(); + let pdf = Pdf::new(registered).unwrap(); + let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + let config = catalog.get::>(b"OCProperties").unwrap().get::>(b"D").unwrap(); + assert_eq!(references(&config, b"OFF").unwrap(), Vec::new()); + } + + /// The incremental writer alone, so a reader's own handling of the input can't decide the result. + fn update(bytes: &[u8]) -> Result> { + append(bytes, &[(ObjectIdentifier::new(4, 0), b"<>".to_vec())], ObjectIdentifier::new(1, 0)) + } + + #[test] + fn a_null_encrypt_entry_is_no_encryption() { + for trailer in ["<>", "<>"] { + assert!(update(&default_hidden(trailer, None)).is_ok(), "{trailer}"); + } + for trailer in ["<>", "<>>>"] { + assert!(update(&default_hidden(trailer, None)).is_err(), "{trailer}"); + } + // End to end, a null entry still lets a layer choice be written. + assert!(prepare(&default_hidden("<>", None), None).is_ok()); + } + + #[test] + fn encryption_in_an_earlier_revision_is_found() { + let bytes = default_hidden("<>", Some("<>")); + assert!(update(&bytes).is_err()); + let bytes = default_hidden("<>", Some("<>")); + assert!(update(&bytes).is_ok()); + } + + #[test] + fn a_trailer_in_a_comment_does_not_hide_the_real_one() { + let bytes = b"xref\n% trailer <>\n0 1\n0000000000 65535 f\r\ntrailer\n<>"; + assert!(trailer_at(bytes, 0).is_none(), "a comment inside the table is not table syntax"); + let bytes = b"xref\n0 1\n0000000000 65535 f\r\ntrailer\n<>"; + assert!(has_entry(&trailer_at(bytes, 0).unwrap(), b"Encrypt")); + } + + #[test] + fn a_cyclic_prev_chain_is_damage() { + let mut bytes = default_hidden("<>", None).to_vec(); + let at = crate::transitions::rfind(&bytes, b"xref\n").unwrap(); + let patched = String::from_utf8(bytes.clone()).unwrap().replacen("<>", &format!("<>"), 1); + bytes = patched.into_bytes(); + let error = update(&bytes).unwrap_err().to_string(); + assert!(error.contains("cross-reference"), "{error}"); + } } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 655669c6a..8f33a020b 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -318,7 +318,7 @@ impl Exporter<'_> { if let Some(clip) = to_path(&c.clip.to_path(0.1)) { s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0)))); - self.graphic(s, g, &[]); + self.graphic(s, g, None); s.pop(); s.pop(); } From 4c580086a0addbb2f28fb4377ad3c1138f9daba7 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:50:42 -0600 Subject: [PATCH 4/6] PDF: a null OCProperties is no optional content; an unreadable Prev is damage A placed file whose catalog has /OCProperties null (literal or through a reference) exported on main but failed after the layer-choice change; treat it as absent. Like the trailer guard in #1670, a non-null /Prev that can't be read as an offset ends nothing and is reported as a damaged cross-reference table. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/optional_content.rs | 16 +++++++++++++++- crates/pdf/src/registration_tests.rs | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs index 0dd0c4879..3a9e09289 100644 --- a/crates/pdf/src/optional_content.rs +++ b/crates/pdf/src/optional_content.rs @@ -123,6 +123,9 @@ fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIde return Err(invalid()); } offset = section.get::(b"Prev"); + if offset.is_none() && has_entry(§ion, b"Prev") { + return Err(damaged()); + } } if offset.is_some() { return Err(damaged()); @@ -180,7 +183,8 @@ pub(crate) fn prepare(bytes: &Arc>, choice: Option<&[String]>) -> Result let pdf = Pdf::new(bytes.clone()).map_err(|_| invalid())?; let xref = pdf.xref(); let root = xref.get::>(xref.root_id()).ok_or_else(invalid)?; - if !root.contains_key(b"OCProperties") { + // A null value, literal or referenced, is the same as no entry. + if matches!(root.get::>(b"OCProperties"), None | Some(Object::Null(_))) { let has_optional_content = pdf .objects() .into_iter() @@ -433,6 +437,16 @@ mod tests { assert!(has_entry(&trailer_at(bytes, 0).unwrap(), b"Encrypt")); } + #[test] + fn an_unreadable_prev_is_damage_and_a_null_one_ends_the_chain() { + for value in ["(12)", "/Twelve", "-1", "[12]", "<<>>", "true", "9 0 R"] { + let bytes = default_hidden(&format!("<>"), None); + let error = update(&bytes).unwrap_err().to_string(); + assert!(error.contains("cross-reference"), "{value}: {error}"); + } + assert!(update(&default_hidden("<>", None)).is_ok()); + } + #[test] fn a_cyclic_prev_chain_is_damage() { let mut bytes = default_hidden("<>", None).to_vec(); diff --git a/crates/pdf/src/registration_tests.rs b/crates/pdf/src/registration_tests.rs index d87c4c838..278100304 100644 --- a/crates/pdf/src/registration_tests.rs +++ b/crates/pdf/src/registration_tests.rs @@ -137,6 +137,25 @@ fn unlayered_placed_pdf_skips_optional_content_registration() { check_unlayered(&placed_document(unlayered_source()), true); } +#[test] +fn null_optional_content_properties_are_no_optional_content() { + let content = "0.125 0.625 0.875 rg 0 0 100 100 re f"; + for (properties, extra) in [("/OCProperties null", None), ("/OCProperties 5 0 R", Some("null"))] { + let mut objects = vec![ + format!("<>"), + "<>".into(), + "<>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ]; + objects.extend(extra.map(String::from)); + let doc = placed_document(assemble(&objects)); + for standard in [Standard::None, Standard::PdfX4] { + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + check_unlayered_pdf(&bytes, 1, true); + } + } +} + #[test] fn layered_and_resource_only_inputs_register_optional_content() { for hidden in [true, false] { From a16f29a6697fe0e2cfa82e9b47216b234cf28cbf Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:45:04 -0400 Subject: [PATCH 5/6] Optical margin alignment: lines with a tab hang their edge glyphs, as both line breakers measure them (#1760) layout_line set a line with a tab or a right indent tab without the optical margin or CJK edge hangs, while the greedy breaker and the paragraph composer counted them. A line that fit only because its period hangs was then squeezed into the measure or set past it. layout_line now hangs the edge glyphs of every line. Tabs are measured from the hung start, so tab stops stay where they are, and a right indent tab fills to the hung end. A tab at a line edge hangs nothing. Lines without tabs lay out as before. --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 34 ++++++++++++----------- crates/compose/src/tests.rs | 55 +++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8b1788a0..1bb8c7379 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Optical Margin Alignment and hanging punctuation apply to lines with a tab or a right indent tab, as both composers already measured them: the edge glyphs hang, and tab stops stay where they are. Such a line was set without the hang: a justified line that fit only because its period or closing quote hangs had its word spaces squeezed to make up for it, and text after a right indent tab stopped at the measure instead of hanging. No readiness percentage change. - **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change. - **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change. - **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6b6a61618..543f2430d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3419,9 +3419,20 @@ fn layout_line( let h = shape::hyphen_after(g); line.push(h); } - // Tabs: compute widths left to right. let measure = x1 - x0; - let mut x = 0.0; + // Optical margin alignment and hanging punctuation: the glyphs at the line's edges hang + // outside the measure. A tab at an edge hangs nothing. + let is_tab = |g: &Glyph| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB; + let at_edge = |g: &&Glyph| is_tab(g) || (g.adv > 0.0 && !g.is_space()); + let first = line.iter().find(at_edge); + let lastg = line.iter().rev().find(at_edge); + let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp)); + let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp)); + let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang); + let regular_hang = pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular; + // Tabs: compute widths left to right. Tab stops stay where they are when the line hangs, so + // tabs are measured from the hung start, and a right indent tab reaches the hung end. + let mut x = -hl; let mut i = 0; let mut leaders: Vec<(usize, String)> = Vec::new(); // The line's last tab and whether it is a left tab (only text after a left tab justifies). @@ -3451,26 +3462,17 @@ fn layout_line( leaders.push((i, t.leader.clone())); } let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); - line[i].adv = (measure - x - rest).max(0.0); + let hr = if regular_hang { optical_right } else { optical_right.max(cjk_right) }; + line[i].adv = (measure + hr - x - rest).max(0.0); last_tab = Some((i, false)); } x += line[i].adv; i += 1; } - let has_tab = line.iter().any(|g| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB); - // Optical margin alignment: the measure grows by the hang of the edge glyphs. + // The measure grows by the hang of the edge glyphs. let (mut x0, mut measure) = (x0, measure); - if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab { - let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space()); - let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space()); - let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp)); - let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp)); - let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang); - let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular { - cjk_right.min((line.iter().map(|g| g.adv).sum::() - measure).max(0.0)) - } else { - cjk_right - }; + if sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None { + let cjk_right = if regular_hang { cjk_right.min((line.iter().map(|g| g.adv).sum::() - measure).max(0.0)) } else { cjk_right }; let hr = optical_right.max(cjk_right); x0 -= hl; measure += hl + hr; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 90b4ac3c4..54d0852f0 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1358,6 +1358,61 @@ fn optical_margin_hangs_punctuation() { assert!(lines.iter().all(|l| l.end_x <= l.x1 + 12.0)); } +#[test] +fn optical_margin_hangs_the_edges_of_lines_with_tabs() { + // Both line breakers let the edge glyphs of a line hang, tab or not, so layout hangs them + // too: a line that fits only because its period hangs is not squeezed into the measure or + // set past it. The left hang leaves tab stops where they are. + let stop = designcraft_doc::TabStop { position: 40.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + let rest = "right here. More words follow it on the next line."; + let cases = [ + ("tab", format!("“A”\tsits {rest}"), Align::LeftJustified), + ("no tab", format!("“A” sits {rest}"), Align::LeftJustified), + ("right indent tab", format!("“A” sits{}{rest}", story::RIGHT_INDENT_TAB), Align::Left), + ]; + for (name, text, align) in &cases { + let ch = |g: &PlacedGlyph| text.get(g.byte..).and_then(|s| s.chars().next()).unwrap_or(' '); + let edge_glyphs = |l: &Line| -> Vec { l.glyphs.iter().filter(|g| g.visible && !ch(g).is_whitespace()).cloned().collect() }; + for composer in [Composer::Paragraph, Composer::SingleLine] { + let mut ending = 0; + for w in 100..=240 { + let para = ParaAttrs { + align: Some(*align), + optical_margin: Some(true), + hyphenate: Some(false), + composer: Some(composer), + tabs: Some(vec![stop.clone()]), + ..Default::default() + }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, f64::from(w), 1000.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + for (i, l) in lines.iter().enumerate() { + let Some(last) = edge_glyphs(l).last().cloned() else { continue }; + let hang = breaker::hang(ch(&last)).1 * last.adv; + let end = last.x + last.adv; + assert!(end <= l.x1 + hang + 0.01, "{name}, {composer:?}, width {w}: line {i} ends at {end}, past {} and its hang {hang}", l.x1); + } + let glyphs = edge_glyphs(lines[0]); + let (x0, x1) = (lines[0].x0, lines[0].x1); + let first = &glyphs[0]; + assert!((first.x + first.adv - x0).abs() < 0.01, "{name}, {composer:?}, width {w}: the opening quote is at {}", first.x); + if *name == "tab" { + let s = glyphs.iter().find(|g| ch(g) == 's').unwrap(); + assert!((s.x - (x0 + 40.0)).abs() < 0.01, "{composer:?}, width {w}: the text after the tab starts at {}", s.x); + } + // Line 0 ends with the period of "here.": the period hangs past the measure. + let last = glyphs.last().unwrap(); + if text.get(last.byte..).is_some_and(|s| s.starts_with(". More")) { + ending += 1; + assert!((last.x - x1).abs() < 0.01, "{name}, {composer:?}, width {w}: the period starts at {}, not at {x1}", last.x); + } + } + assert!(ending > 3, "{name}, {composer:?}: line 0 ends with the period at {ending} widths"); + } + } +} + /// How far the opening quote of a one-line paragraph at `text_size` hangs into the left margin, /// with the story's Optical Margin Alignment `story` (on, size) and the paragraph's attributes. fn quote_hang(text_size: f64, story: Option, para: ParaAttrs) -> f64 { From b595f1b7d3dbc3dbbe3344190dcb466f2d50ae54 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:56:49 -0400 Subject: [PATCH 6/6] EPUB: Split Document (export option and per-style Export Tagging) (#1787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * EPUB: Split Document in a paragraph style's Export Tagging * EPUB: Split Document as a document setting (off, one style, or export tags) The document stores EPUB Export Options › Split Document (DocSettings.epub_split): off (one content file, the default), a single paragraph style, or the paragraph styles' Export Tagging › Split Document flags. document.epubSplit sets it as one undo step; file.exportEpub takes a split that overrides it for one export. A Style setting follows a renamed style and a deleted style's replacement. IDML reads and writes EPubExportPreference BreakDocument and ParagraphStyleName. A split before a list item or preformatted line closes the open list or block first, so each content file is balanced. * EPUB Export Options dialog with Split Document before Export EPUB File › Export EPUB… opens EPUB Export Options, seeded from the document's Split Document setting: a Split Document checkbox with Single Paragraph Style (and its style) or Based on Paragraph Style Export Tags. OK stores a changed setting through document.epubSplit, then continues to the save dialog; Cancel does nothing. app.exportEpub with a path skips the dialog. Docs and the progress log describe the setting. * EPUB: the navigation test exports one content file with Split Document off * EPUB export from the menu passes its options (cover, split) to the engine * EPUB: Split Document on a style's EPUB tag map counts when the style says false; a caller's cover: false stays --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 24 +++ crates/doc/src/styles.rs | 4 + crates/engine/src/cmd/export.rs | 73 +++++++- crates/engine/src/cmd/load_styles.rs | 6 +- crates/engine/src/cmd/style.rs | 23 ++- crates/epub/src/lib.rs | 205 +++++++++++++++++++--- crates/epub/tests/it/export_tag_styles.rs | 4 +- crates/idml/src/export.rs | 12 ++ crates/idml/src/import.rs | 26 +++ crates/idml/src/tests.rs | 54 ++++++ crates/ui-egui/src/dialogs.rs | 137 ++++++++++++++- crates/ui-egui/src/menus.rs | 30 +++- docs/target-app-parity.md | 3 +- 14 files changed, 561 insertions(+), 41 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bb70baf15..4337c28d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. - **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a509fdd8b..d8d197387 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -343,6 +343,26 @@ pub enum ChapterSource { SameAsPrevious, } +/// EPUB Export Options › Split Document: where a reflowable EPUB starts a new content file. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EpubSplit { + /// One content file. + #[default] + Off, + /// Single Paragraph Style: a file starts before each paragraph of the named style. + Style(String), + /// Based on Paragraph Style Export Tags: a file starts before each paragraph of a style whose + /// Export Tagging has Split Document on. + ExportTags, +} + +impl EpubSplit { + pub fn is_off(&self) -> bool { + *self == EpubSplit::Off + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct DocSettings { @@ -417,6 +437,9 @@ pub struct DocSettings { /// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets /// with nothing selected (InDesign's document defaults). pub new_object: NewObjectColors, + /// EPUB Export Options › Split Document. + #[serde(skip_serializing_if = "EpubSplit::is_off")] + pub epub_split: EpubSplit, } fn is_true(b: &bool) -> bool { @@ -482,6 +505,7 @@ impl Default for DocSettings { zero_point_locked: false, column_guides_locked: true, new_object: NewObjectColors::default(), + epub_split: EpubSplit::Off, } } } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 5c747fdbf..7605cc447 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -113,6 +113,10 @@ pub struct ExportTag { pub tag: String, /// CSS class (empty = from the style name). pub class: String, + /// Split Document (EPUB only): an EPUB content file starts before each paragraph of the + /// style, when the document's EPUB split is based on export tags (`EpubSplit::ExportTags`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub split: bool, } impl Styles { diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 52886c33c..908814809 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -13,8 +13,30 @@ pub fn specs() -> Vec { "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded", + "{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), split?: \"off\"|\"exportTags\"|{style: name} (Split Document for this export, not stored; the document's `document.epubSplit` by default), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded", has_doc, export_epub), + cmd!( + "document.epubSplit", + "EPUB Split Document", + [], + None, + "{split?: \"off\"|\"exportTags\"|{style: name}} — EPUB Export Options › Split Document, stored with the document: one content file (off, the default), a file before each paragraph whose style has Export Tagging › Split Document on (exportTags), or before each paragraph of one style → {split}", + has_doc, + |s, p| { + const ID: &str = "document.epubSplit"; + let cur = s.doc()?.doc.settings.epub_split.clone(); + let Some(v) = p.get("split") else { return Ok(json!({"split": cur})) }; + let split = epub_split_param(ID, &s.doc()?.doc, v)?; + if split != cur { + let stored = split.clone(); + s.edit(move |d, _| { + d.settings.epub_split = stored; + Ok(Value::Null) + })?; + } + Ok(json!({"split": split})) + } + ), cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", has_doc, |s, p| { @@ -242,8 +264,22 @@ fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> out } +/// A Split Document setting (`"off"`, `"exportTags"` or `{"style": name}`), naming a paragraph +/// style the document has. +pub(crate) fn epub_split_param(id: &str, doc: &designcraft_doc::Document, v: &Value) -> Result { + let split: designcraft_doc::EpubSplit = + serde_json::from_value(v.clone()).map_err(|_| bad(id, "`split`: \"off\", \"exportTags\" or {\"style\": name}"))?; + if let designcraft_doc::EpubSplit::Style(name) = &split + && doc.styles.para(name).is_none() + { + return Err(bad(id, format!("no paragraph style `{name}`"))); + } + Ok(split) +} + fn export_epub(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; + let split = p.get("split").map(|v| epub_split_param("file.exportEpub", &st.doc, v)).transpose()?; // Object Export Options › Rasterize: those objects go in as images. let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it)); let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); @@ -270,6 +306,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string), cover, toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string), + split, generic_families: generic_families(&st.doc), fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed { embedded_fonts(&st.doc, &mut warnings) @@ -848,6 +885,40 @@ mod text_tests { s } + /// Split Document is stored with the document as one undo step, follows a renamed style, and + /// an export can override it without storing its own. + #[test] + fn epub_split_document_setting() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nText\nTwo\nMore"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap(); + for at in [0, 9] { + s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap(); + } + let files = |s: &mut Session, p: serde_json::Value| { + let mut z = unzip(s.execute("file.exportEpub", &p).unwrap()["base64"].as_str().unwrap()); + (1..).take_while(|n| z.by_name(&if *n == 1 { "OEBPS/content.xhtml".into() } else { format!("OEBPS/content{n}.xhtml") }).is_ok()).count() + }; + assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off"); + assert_eq!(files(&mut s, json!({})), 1, "one content file by default"); + assert!(s.execute("document.epubSplit", &json!({"split": {"style": "Nope"}})).is_err()); + assert!(s.execute("document.epubSplit", &json!({"split": "h1"})).is_err()); + assert!(s.execute("file.exportEpub", &json!({"split": {"style": "Nope"}})).is_err()); + assert_eq!(s.execute("document.epubSplit", &json!({"split": {"style": "Chapter"}})).unwrap()["split"], json!({"style": "Chapter"})); + assert_eq!(files(&mut s, json!({})), 2); + assert_eq!(files(&mut s, json!({"split": "off"})), 1); + assert_eq!(s.doc().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Style("Chapter".into()), "the override isn't stored"); + s.execute("style.paragraph.edit", &json!({"name": "Chapter", "rename": "Part"})).unwrap(); + assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], json!({"style": "Part"})); + assert_eq!(files(&mut s, json!({})), 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off"); + } + #[test] fn object_export_options() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/load_styles.rs b/crates/engine/src/cmd/load_styles.rs index b89b42fa5..acc20f719 100644 --- a/crates/engine/src/cmd/load_styles.rs +++ b/crates/engine/src/cmd/load_styles.rs @@ -583,8 +583,8 @@ mod tests { d.settings.lists.push(NumberedList { name: "Steps".into(), continue_across_stories: false }); d.settings.lists.push(NumberedList { name: "Unused".into(), ..Default::default() }); let st = d.styles_mut(); - st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into() }); - st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new() }); + st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into(), split: true }); + st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new(), ..Default::default() }); st.para_mut("Quote").unwrap().para.list_name = Some("Steps".into()); Ok(()) }) @@ -592,7 +592,7 @@ mod tests { let mut s = doc_with_body_and_lead(); s.execute("style.load", &json!({"base64": file_of(&src), "conflicts": "autoRename"})).unwrap(); let d = &s.doc().unwrap().doc; - assert_eq!(d.styles.export_tag("Head", false).map(|t| t.tag.as_str()), Some("h1")); + assert_eq!(d.styles.export_tag("Head", false).map(|t| (t.tag.as_str(), t.split)), Some(("h1", true)), "Split Document comes along"); assert_eq!(d.styles.export_tag("Lead 2", true).map(|t| t.tag.as_str()), Some("em"), "the tag follows the new name"); assert_eq!(d.styles.export_tag("Lead", true), None); assert_eq!(d.settings.lists.iter().map(|l| (l.name.as_str(), l.continue_across_stories)).collect::>(), [("Steps", false)]); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index ab24a05d8..9568d842e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -249,7 +249,7 @@ pub fn specs() -> Vec { "Export Tagging", [], None, - "{style, character?: bool, tag?: p|h1…h6|blockquote|pre|li|… (span|em|strong|code|sup|sub… for characters; \"\" = automatic), class?} → the tagging", + "{style, character?: bool, tag?: p|h1…h6|blockquote|pre|li|… (span|em|strong|code|sup|sub… for characters; \"\" = automatic), class?, split?: bool (paragraphs: Split Document, EPUB only)} → the tagging", has_doc, |s, p| { let name = str_param(p, "style").ok_or_else(|| bad("style.exportTag", "`style` required"))?.to_string(); @@ -260,6 +260,10 @@ pub fn specs() -> Vec { } let key = format!("{}:{name}", if character { "c" } else { "p" }); let (tag, class) = (str_param(p, "tag").map(str::to_string), str_param(p, "class").map(str::to_string)); + let split = p.get("split").and_then(Value::as_bool); + if character && split == Some(true) { + return Err(bad("style.exportTag", "`split` is for paragraph styles")); + } s.edit(|d, _| { let st = d.styles_mut(); let e = st.export_tags.entry(key.clone()).or_default(); @@ -269,8 +273,11 @@ pub fn specs() -> Vec { if let Some(c) = class.clone() { e.class = c; } + if let Some(v) = split { + e.split = v; + } let out = serde_json::to_value(&*e).unwrap_or_default(); - if e.tag.is_empty() && e.class.is_empty() { + if *e == designcraft_doc::ExportTag::default() { st.export_tags.remove(&key); } Ok(out) @@ -1020,6 +1027,9 @@ fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str *r = Some(to.to_string()); } } + if let designcraft_doc::EpubSplit::Style(n) = &mut d.settings.epub_split { + rename(n); + } } else { let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); for s in &mut st.character { @@ -2810,6 +2820,13 @@ mod export_tag_tests { s.execute("style.exportTag", &json!({"style": "Chapter Title", "tag": "h1", "class": "chapter"})).unwrap(); s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "tag": "strong"})).unwrap(); assert!(s.execute("style.exportTag", &json!({"style": "Nope"})).is_err()); + // Split Document is for paragraph styles; it alone keeps the tagging. + assert!(s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "split": true})).is_err()); + s.execute("style.paragraph.create", &json!({"name": "Part"})).unwrap(); + assert_eq!(s.execute("style.exportTag", &json!({"style": "Part", "split": true})).unwrap()["split"], true); + assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_some_and(|t| t.split)); + s.execute("style.exportTag", &json!({"style": "Part", "split": false})).unwrap(); + assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_none(), "an untouched tagging goes"); let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Origins\nA word"})).unwrap(); let sid = r["story"].as_u64().unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); @@ -2981,6 +2998,7 @@ mod delete_tests { d.footnote_options.para_style = "Table Body".into(); d.endnote_options.para_style = "Table Body".into(); d.endnote_options.heading_style = "Table Body".into(); + d.settings.epub_split = designcraft_doc::EpubSplit::Style("Table Body".into()); d.toc = Some(Toc { story, title: "Contents".into(), @@ -2999,6 +3017,7 @@ mod delete_tests { assert_eq!((sub.based_on.as_deref(), sub.next_style.as_deref()), (Some("Body"), Some("Body"))); assert_eq!(d.styles.default_paragraph, "Body"); assert_eq!(d.endnote_options.heading_style, "Body"); + assert_eq!(d.settings.epub_split, designcraft_doc::EpubSplit::Style("Body".into()), "EPUB Split Document follows the replacement"); let after = s.execute("preflight.run", &json!({})).unwrap(); assert!(!after.to_string().contains("Table Body"), "{after}"); } diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 62de05cb7..38cfd68ad 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -15,8 +15,8 @@ use std::io::Write; use designcraft_doc::geom::{Affine, Rect}; use designcraft_doc::{ - Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK, ParaProps, - SpreadRef, Story, StoryId, Styles, Table, story, + Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, EpubSplit, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK, + ParaProps, SpreadRef, Story, StoryId, Styles, Table, story, }; #[derive(Clone, Debug)] @@ -38,6 +38,8 @@ pub struct EpubOptions { pub modified: Option, /// Title of the navigation (table of contents); from `language` when absent. pub toc_title: Option, + /// Split Document: where content files start; the document's setting when absent. + pub split: Option, } /// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). @@ -288,6 +290,7 @@ impl Default for EpubOptions { fonts: Vec::new(), modified: None, toc_title: None, + split: None, } } } @@ -669,6 +672,8 @@ struct StoryWriter<'a, 'f> { /// Prefix of the `id`s the current story's headings get (`{prefix}-p{paragraph index}`, the /// targets of the table of contents); only top-level paragraphs get one. heading_ids: Option, + /// Where EPUB content files start (see [`starts_chapter`]). + split: EpubSplit, } impl<'a, 'f> StoryWriter<'a, 'f> { @@ -687,6 +692,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> { images: BTreeMap::new(), no_alt: Vec::new(), heading_ids: None, + split: doc.settings.epub_split.clone(), } } @@ -725,6 +731,12 @@ impl<'a, 'f> StoryWriter<'a, 'f> { let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &self.pc); let props = doc.styles.resolve_para(pf).0; let rtl = props.direction == designcraft_doc::TextDirection::RightToLeft; + // Chapter files start only at top-level paragraphs, after any open list or + // preformatted block is closed, so each file stays balanced. + if depth == 0 && self.heading_ids.is_some() && starts_chapter(&self.split, &doc.styles, &pf.style) { + close_blocks(&mut list, &mut pre, out); + out.push_str(CHAPTER_MARK); + } let continues_pre = tag == "pre" && pre.as_ref() == Some(&(class.clone(), rtl)); if !continues_pre && pre.take().is_some() { out.push_str("\n"); @@ -741,11 +753,6 @@ impl<'a, 'f> StoryWriter<'a, 'f> { } list = list_tag; } - // An EPUB chapter file starts before each top-level h1, outside any open list or - // preformatted block. - if depth == 0 && self.heading_ids.is_some() && heading_level(tag) == Some(1) { - out.push_str(CHAPTER_MARK); - } if continues_pre { out.push('\n'); } else { @@ -1050,7 +1057,18 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { StoryWriter::new(doc, true, None).story(sid, None) } -/// Where the EPUB body splits into chapter files: before each `h1`. +/// Whether an EPUB chapter file starts before a paragraph of `style` (Split Document): never +/// when it's off, before each paragraph of the one style it names, or before each paragraph of +/// a style whose Export Tagging has Split Document on. +fn starts_chapter(split: &EpubSplit, st: &Styles, style: &str) -> bool { + match split { + EpubSplit::Off => false, + EpubSplit::Style(name) => name == style, + EpubSplit::ExportTags => st.export_tag(style, false).is_some_and(|e| e.split), + } +} + +/// Where the EPUB body splits into chapter files (see [`starts_chapter`]). const CHAPTER_MARK: &str = ""; /// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed @@ -1306,9 +1324,12 @@ struct Body<'a> { /// The content in reading order as HTML sections and figures; `img_src` names each image /// (a package path, or a data URI for single-file HTML). -fn body_html<'a>(doc: &'a Document, epub: bool, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> { +fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSplit>, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> { let order = reading_order(doc); let mut writer = StoryWriter::new(doc, epub, Some(img_src)); + if let Some(split) = split { + writer.split = split.clone(); + } let mut body = String::new(); let mut toc = toc_entries(doc, &order); for (k, b) in order.iter().enumerate() { @@ -1355,7 +1376,7 @@ fn body_html<'a>(doc: &'a Document, epub: bool, img_src: &dyn Fn(&str, &str, &[u /// Placed and anchored images (not video or sound) without alt text, by asset name: they are exported as /// decorative (`alt=""`), which an export can report. pub fn images_without_alt(doc: &Document) -> Vec { - body_html(doc, true, &|name, _, _| name.to_string()).no_alt + body_html(doc, true, None, &|name, _, _| name.to_string()).no_alt } /// Options for [`export_html`]. @@ -1373,7 +1394,7 @@ pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let language = opts.language.as_deref().unwrap_or("en"); let lang = language_tag(language); - let body = body_html(doc, false, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; + let body = body_html(doc, false, None, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), @@ -1398,9 +1419,9 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); - let Body { html: body, images, toc, .. } = body_html(doc, true, &|name, _, _| name.to_string()); + let Body { html: body, images, toc, .. } = body_html(doc, true, opts.split.as_ref(), &|name, _, _| name.to_string()); let lang = language_tag(&opts.language); - // One file per chapter (split before each h1), so e-readers needn't load the whole book. + // One file per chapter (see `starts_chapter`); one file in all when Split Document is off. let chapters: Vec = split_chapters(&body) .into_iter() .map(|part| { @@ -1653,7 +1674,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); } let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed"; let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); @@ -1696,7 +1719,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); } let text = "One\nBody\nOne point one\nMore\nTwo\nText"; let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); @@ -1715,12 +1740,12 @@ mod tests { let nl = EpubOptions { language: "nl".into(), ..Default::default() }; let nav = read(&d, &nl, "OEBPS/nav.xhtml"); let link = |f: &str, t: &str, l: &str| format!("{l}"); - // Each h1 starts a chapter file (#768). + // Split Document is off: every heading is in the one content file. let want = format!( "

Inhoud

  1. {}
    1. {}
  2. {}
", link("content.xhtml", "s0-p0", "One"), link("content.xhtml", "s0-p2", "One point one"), - link("content2.xhtml", "s0-p4", "Two") + link("content.xhtml", "s0-p4", "Two") ); assert!(nav.contains(&want), "{nav}"); assert!(!nav.contains("Text after the figure"), "{nav}"); @@ -1760,10 +1785,11 @@ mod tests { ); } - /// #768: the book is split into one file per chapter, before each h1, with well-formed - /// sections; the spine and manifest list the files in order. + /// Split Document off (the default) gives one content file, h1s and all. Single Paragraph + /// Style starts a file before each paragraph of that style, with well-formed sections; the + /// spine and manifest list the files in order. #[test] - fn chapters_are_split_at_h1() { + fn chapters_split_before_a_single_paragraph_style() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); let lid = d.default_layer(); d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { @@ -1774,7 +1800,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); // A title page without a heading, then a story that opens with a chapter and has a // second one in the middle. d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); @@ -1784,6 +1812,15 @@ mod tests { let st = d.story_mut(sid).unwrap(); st.paras[0].style = "Chapter".into(); st.paras[2].style = "Chapter".into(); + let one_file = |d: &Document, opts: &EpubOptions| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut c = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut c).unwrap(); + z.by_name("OEBPS/content2.xhtml").is_err() && c.contains("Title page") && c.contains("Second text") && !c.contains(CHAPTER_MARK) + }; + assert!(one_file(&d, &EpubOptions::default()), "off by default"); + d.settings.epub_split = EpubSplit::Style("Chapter".into()); + assert!(one_file(&d, &EpubOptions { split: Some(EpubSplit::Off), ..Default::default() }), "the export's own setting wins"); let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); let mut read = |path: &str| { let mut s = String::new(); @@ -1809,6 +1846,61 @@ mod tests { assert!(!html.contains(CHAPTER_MARK) && html.contains("Second text")); } + /// Based on export tags, a style with Split Document on starts each chapter file and an h1 + /// without it doesn't; navigation and endnote links point at the files their targets ended + /// up in. + #[test] + fn chapters_split_before_split_document_styles() { + let mut d = Document::new(&NewDocument::default()); + d.settings.epub_split = EpubSplit::ExportTags; + let lid = d.default_layer(); + for (name, tag, split) in [("Part", "p", true), ("Chapter", "h1", false)] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split, ..Default::default() }); + } + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 200.0, 300.0, 600.0), + lid, + "Part One\nChapter A\ntext a\nPart Two\nChapter B\ntext b", + ParaFormat::default(), + ) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(0, "Part"), (1, "Chapter"), (3, "Part"), (4, "Chapter")] { + st.paras[i].style = style.into(); + } + // After "text a". + st.insert_endnote(25, "End note", ParaFormat::default()); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml")); + assert!(c1.contains("Title page") && !c1.contains("Part One"), "{c1}"); + assert!(c2.contains("Part One") && c2.contains("

").count(), "{c}"); + } + let nav = read("OEBPS/nav.xhtml"); + assert!(nav.contains("href=\"content2.xhtml#s1-p1\"") && nav.contains("href=\"content3.xhtml#s1-p4\""), "{nav}"); + assert!(c2.contains("href=\"content3.xhtml#en-1\""), "{c2}"); + assert!(c3.contains("href=\"content2.xhtml#enref-1\""), "the endnote links back across files: {c3}"); + assert!(z.by_name("OEBPS/content4.xhtml").is_err()); + } + /// Note links stay working across chapter files: endnotes end the book, in the last file. #[test] fn note_links_point_at_the_chapter_file_of_their_target() { @@ -1822,7 +1914,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); let (_, sid) = d .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) .unwrap(); @@ -1832,6 +1926,7 @@ mod tests { // After "First": an endnote and a footnote. st.insert_endnote(9, "End note", ParaFormat::default()); st.insert_note(9, "Foot note", ParaFormat::default()); + d.settings.epub_split = EpubSplit::Style("Chapter".into()); let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); let mut read = |path: &str| { let mut s = String::new(); @@ -1844,6 +1939,61 @@ mod tests { assert!(last.contains("Second text") && last.contains(""), "{last}"); } + /// A split before a list item or a preformatted line closes the open list or block first and + /// reopens it in the new file, so every file is balanced. + #[test] + fn chapter_files_split_inside_lists_and_pre_stay_balanced() { + let mut d = Document::new(&NewDocument::default()); + d.settings.epub_split = EpubSplit::ExportTags; + let lid = d.default_layer(); + for (name, tag) in [("Item", "li"), ("Code", "pre")] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split: true, ..Default::default() }); + } + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 300.0), + lid, + "Intro +one +two +x = 1 +y = 2 +End", + ParaFormat::default(), + ) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(1, "Item"), (2, "Item"), (3, "Code"), (4, "Code")] { + st.paras[i].style = style.into(); + } + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let files: Vec = + ["content", "content2", "content3", "content4", "content5"].iter().map(|f| read(&format!("OEBPS/{f}.xhtml"))).collect(); + for (c, text) in files.iter().zip(["Intro", "one", "two", "x = 1", "y = 2"]) { + assert!(c.contains(text), "{text}: {c}"); + for (open, close) in [(""), (""), ("")] { + assert_eq!(c.matches(open).count(), c.matches(close).count(), "{open}: {c}"); + } + } + assert!(files[1].contains(" \"D\"", ParaFormat::default()).unwrap(); - d.styles_mut() - .export_tags - .insert(format!("p:{}", story::BASIC_PARAGRAPH), designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into() }); + d.styles_mut().export_tags.insert( + format!("p:{}", story::BASIC_PARAGRAPH), + designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into(), ..Default::default() }, + ); if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { basic.chars.font_family = Some("Quoted \"Serif\" ".into()); } @@ -2170,7 +2321,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert("p:Grouped".into(), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert("p:Grouped".into(), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); let sid = d.stories.keys().copied().find(|sid| d.story(*sid).is_some_and(|st| !st.notes.is_empty())).unwrap(); for p in &mut d.story_mut(sid).unwrap().paras { p.style = "Grouped".into(); diff --git a/crates/epub/tests/it/export_tag_styles.rs b/crates/epub/tests/it/export_tag_styles.rs index b25d31c36..21a280242 100644 --- a/crates/epub/tests/it/export_tag_styles.rs +++ b/crates/epub/tests/it/export_tag_styles.rs @@ -39,8 +39,8 @@ fn fixture() -> Document { fn map(doc: &mut Document, paragraph_tag: &str, paragraph_class: &str, character_tag: &str, character_class: &str) { let tags = &mut doc.styles_mut().export_tags; - tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into() }); - tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into() }); + tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into(), ..Default::default() }); + tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into(), ..Default::default() }); } fn package(doc: &Document) -> (String, String) { diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 3a9dc778d..2ec0d7105 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -778,6 +778,15 @@ impl<'a> Ex<'a> { designcraft_doc::BlendSpace::Rgb => "RGB", }, )); + // Split Document: the style is a reference, a built-in one by its `$ID/` name; an empty + // one splits on the styles' export tags. + let (break_doc, split_style) = match &s.epub_split { + designcraft_doc::EpubSplit::Off => (false, names::style_name_out(PARA_BUILTINS, st::BASIC_PARAGRAPH)), + designcraft_doc::EpubSplit::ExportTags => (true, String::new()), + designcraft_doc::EpubSplit::Style(n) if PARA_BUILTINS.iter().any(|(o, _)| o == n) => (true, names::style_name_out(PARA_BUILTINS, n)), + designcraft_doc::EpubSplit::Style(n) => (true, names::style_self("ParagraphStyle", PARA_BUILTINS, n)), + }; + root.push(El::new("EPubExportPreference").attr("BreakDocument", bool_s(break_doc)).attr("ParagraphStyleName", split_style)); let a = &s.advanced_type; root.push( El::new("TextPreference") @@ -848,6 +857,9 @@ impl<'a> Ex<'a> { let pa = with_list_tab(pa, || (styles.resolve_para_style(&ps.name).0, parent_props)); self.para_attrs(&mut el, &mut props, &pa, parent); self.char_attrs(&mut el, &mut props, &ca); + if styles.export_tag(&ps.name, false).is_some_and(|t| t.split) { + el.set("SplitDocument", "true"); + } pg.insert(&ps.name, with_props(el, props)); } let mut rp = El::new("RootParagraphStyleGroup").attr("Self", self.fresh()); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 1000638d3..548834182 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -682,6 +682,9 @@ impl<'r> Importer<'r> { if let Some(e) = top.iter().find(|e| e.local() == "FootnoteOption") { self.footnote_option(e); } + if let Some(e) = top.iter().find(|e| e.local() == "EPubExportPreference") { + self.epub_split(e); + } for e in top.iter().filter(|e| e.local() == "Layer") { let id = LayerId(self.alloc()); let color = match e.prop_el("LayerColor") { @@ -1187,6 +1190,14 @@ impl<'r> Importer<'r> { Some(slot) => *slot = s, None => self.styles.paragraph.push(s), } + // Split Document (EPUB only): on the style, and on its EPUB export tag map. Files write + // `false` on every style, so either place saying `true` turns it on. + let split = e.get("SplitDocument") == Some("true") + || e.elements() + .any(|m| m.local() == "StyleExportTagMap" && m.get("ExportType") == Some("EPUB") && m.get("SplitDocument") == Some("true")); + if split { + self.styles.export_tags.entry(format!("p:{name}")).or_default().split = true; + } } for (_, e) in &chars { let name = names::style_name_in(CHAR_BUILTINS, e.get("Name").unwrap_or("")); @@ -1461,6 +1472,21 @@ impl<'r> Importer<'r> { } } + /// Preferences › `EPubExportPreference`: Split Document (after styles: it can name one). An + /// empty style name splits on the styles' export tags; a style the file lacks splits nothing. + fn epub_split(&mut self, e: &El) { + if e.boolean("BreakDocument") != Some(true) { + return; + } + let name = e.get("ParagraphStyleName").unwrap_or("$ID/NormalParagraphStyle"); + self.settings.epub_split = if name.trim().is_empty() { + designcraft_doc::EpubSplit::ExportTags + } else { + let style = self.para_style_ref(name); + if self.styles.para(&style).is_some() { designcraft_doc::EpubSplit::Style(style) } else { designcraft_doc::EpubSplit::Off } + }; + } + /// Preferences › `FootnoteOption` (after styles: it names the footnote text and marker styles). fn footnote_option(&mut self, e: &El) { let mut o = designcraft_doc::FootnoteOptions::default(); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index fcee91706..5eedad4a0 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4631,3 +4631,57 @@ fn style_names_with_a_literal_colon_survive_a_round_trip() { assert_eq!(names::style_name_in(names::PARA_BUILTINS, &names::style_name_out(names::PARA_BUILTINS, n)), n); } } + +/// Split Document (EPUB only) comes from the style, or from its EPUB export tag map, and is +/// written back on the style. +#[test] +fn split_document_round_trips() { + let xml = r#" + + + + + + + + + +"#; + let d = import_idml_with(&fixture_with_styles(xml), &|_| None).unwrap(); + let split = |d: &designcraft_doc::Document, name: &str| d.styles.export_tag(name, false).is_some_and(|t| t.split); + assert!(split(&d, "Part") && split(&d, "Chapter") && !split(&d, "Body")); + let back = import_idml(&export_idml(&d)).unwrap(); + assert!(split(&back, "Part") && split(&back, "Chapter") && !split(&back, "Body")); +} + +/// EPUB Export Options › Split Document: off, one style (built in or not) or the styles' export +/// tags, read from the file's `EPubExportPreference` and written back. +#[test] +fn epub_split_document_round_trips() { + use designcraft_doc::EpubSplit; + let styles = + STYLES.replace("", r#""#); + assert!(styles.contains("ParagraphStyle/Chapter"), "the fixture has a paragraph style group"); + for (attrs, want) in [ + (r#"BreakDocument="false" ParagraphStyleName="ParagraphStyle/Chapter""#, EpubSplit::Off), + (r#"BreakDocument="true" ParagraphStyleName="ParagraphStyle/Chapter""#, EpubSplit::Style("Chapter".into())), + (r#"BreakDocument="true" ParagraphStyleName="ParagraphStyle/Text%3aBody""#, EpubSplit::Style("Text/Body".into())), + (r#"BreakDocument="true" ParagraphStyleName="$ID/NormalParagraphStyle""#, EpubSplit::Style(designcraft_doc::BASIC_PARAGRAPH.into())), + (r#"BreakDocument="true" ParagraphStyleName="""#, EpubSplit::ExportTags), + (r#"BreakDocument="true" ParagraphStyleName="ParagraphStyle/Missing""#, EpubSplit::Off), + ] { + let prefs = PREFS.replace("", &format!("")); + let file = zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &styles), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]); + let d = import_idml_with(&file, &|_| None).unwrap(); + assert_eq!(d.settings.epub_split, want, "{attrs}"); + assert_eq!(import_idml(&export_idml(&d)).unwrap().settings.epub_split, want, "{attrs}"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 04d2391f3..9e44501f0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -32,6 +32,7 @@ impl Dialog { let defaults = match id { "newDocument" => crate::new_document::defaults(), "goToPage" => json!({"page": "1"}), + "epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH}), "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}), "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), @@ -353,6 +354,63 @@ pub fn open_pdf_export(app: &mut DesignApp) { app.ui.dialog = Some(Dialog::new("pdfExport", fields)); } +/// File › Export EPUB…: the EPUB Export Options dialog, seeded from the document's Split +/// Document setting. +pub fn open_epub_export(app: &mut DesignApp) { + use designcraft_doc::EpubSplit; + let split = app.session.active().map(|st| st.doc.settings.epub_split.clone()).unwrap_or_default(); + let fields = match split { + EpubSplit::Off => json!({}), + EpubSplit::Style(name) => json!({"split": true, "mode": "style", "style": name}), + EpubSplit::ExportTags => json!({"split": true, "mode": "exportTags"}), + }; + app.ui.dialog = Some(Dialog::new("epubExport", fields)); +} + +/// The Split Document setting the EPUB Export Options dialog's fields describe. +fn epub_export_split(d: &Dialog) -> designcraft_doc::EpubSplit { + use designcraft_doc::EpubSplit; + if !d.b("split") { + EpubSplit::Off + } else if d.s("mode") == "exportTags" { + EpubSplit::ExportTags + } else { + EpubSplit::Style(d.s("style")) + } +} + +fn epub_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.as_str(); + let styles: Vec = app + .session + .active() + .map(|st| st.doc.styles.paragraph.iter().map(|s| s.name.clone()).filter(|n| n != designcraft_doc::NO_PARA_STYLE).collect()) + .unwrap_or_default(); + check(ui, d, "split", crate::i18n::tr(lang, "Split Document")); + ui.add_enabled_ui(d.b("split"), |ui| { + ui.indent("epub_split", |ui| { + let mode = d.s("mode"); + ui.horizontal(|ui| { + if ui.radio(mode != "exportTags", crate::rtl::widget(ui, crate::i18n::tr(lang, "Single Paragraph Style"))).clicked() { + d.fields.insert("mode".into(), json!("style")); + } + let cur = d.s("style"); + egui::ComboBox::from_id_salt("epub_split_style").selected_text(crate::rtl::widget(ui, &cur)).width(180.0).show_ui(ui, |ui| { + for name in &styles { + if ui.selectable_label(cur == *name, crate::rtl::widget(ui, name)).clicked() { + d.fields.insert("style".into(), json!(name)); + d.fields.insert("mode".into(), json!("style")); + } + } + }); + }); + if ui.radio(mode == "exportTags", crate::rtl::widget(ui, crate::i18n::tr(lang, "Based on Paragraph Style Export Tags"))).clicked() { + d.fields.insert("mode".into(), json!("exportTags")); + } + }); + }); +} + fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let printers: Vec = d .fields @@ -2147,6 +2205,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "print" => crate::i18n::tr(&app.ui.language, "Print"), "pdfImport" => crate::i18n::tr(&app.ui.language, "Place PDF"), "pdfExport" => crate::i18n::tr(&app.ui.language, "Export PDF"), + "epubExport" => crate::i18n::tr(&app.ui.language, "EPUB Export Options"), "colorSettings" => crate::i18n::tr(&app.ui.language, "Color Settings"), "layerOptions" => crate::i18n::tr(&app.ui.language, "Object Layer Options"), "keyboardShortcuts" => crate::i18n::tr(&app.ui.language, "Keyboard Shortcuts"), @@ -2885,6 +2944,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "numberingSection" => crate::section_options::body(app, ui, &mut d), "pdfExport" => pdf_export(app, ui, &mut d), + "epubExport" => epub_export(app, ui, &mut d), "alert" => { let file = d.s("file"); if !file.is_empty() { @@ -3410,6 +3470,15 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.status(crate::i18n::tr(&app.ui.language, "Sent {count} page(s) to {printer}").replace("{count}", &r["pages"].to_string()).replace("{printer}", printer)); Ok(r) } + "epubExport" => { + let split = epub_export_split(&d); + let stored = app.session.active().map(|st| st.doc.settings.epub_split.clone()); + if stored.as_ref() != Some(&split) { + let v = serde_json::to_value(&split).map_err(|e| e.to_string())?; + app.run("document.epubSplit", json!({"split": v}))?; + } + crate::menus::export_epub(app, &json!({})) + } "pdfExport" => { // Remember the last-used options; `pages` and `title` are per-job and not persisted. app.ui.pdf_export.preset = d.s("preset"); @@ -3835,7 +3904,7 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul if d.fields.contains_key("x.tag") { let tag = d.s("x.tag"); let tag = if tag == "[Automatic]" { String::new() } else { tag }; - app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class")}))?; + app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class"), "split": d.b("x.split")}))?; } Ok(r) } @@ -3986,6 +4055,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo if !d.fields.contains_key("x.tag") { d.fields.insert("x.tag".into(), json!(if et.tag.is_empty() { "[Automatic]" } else { et.tag.as_str() })); d.fields.insert("x.class".into(), json!(et.class)); + d.fields.insert("x.split".into(), json!(et.split)); } ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Tag:")); @@ -4005,6 +4075,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Class:")); text_field(ui, d, "x.class", 160.0); }); + check(ui, d, "x.split", crate::i18n::tr(&app.ui.language, "Split Document (EPUB only)")); } "nested" => drop_caps_and_nested_styles(&app.ui.language, ui, d, &pv, &cnames), "grep" => grep_styles(&app.ui.language, ui, d, &pv, &cnames), @@ -9887,6 +9958,70 @@ mod tests { assert!(st.doc.styles.char_style(&name).is_none()); } + /// File › Export EPUB… opens EPUB Export Options from the document's setting; OK stores a + /// changed Split Document as one undo step, and an unchanged one adds none. + #[test] + fn epub_export_passes_its_options_to_the_engine() { + use std::io::Read; + use std::sync::{Arc, Mutex}; + let mut app = test_app(); + app.session.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap(); + let r = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\nTwo"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + for anchor in [0, 4] { + app.session.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": anchor})).unwrap(); + app.session.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap(); + } + let written: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = written.clone(); + app.services.write = Some(Box::new(move |_: &str, bytes: &[u8]| { + *sink.lock().unwrap() = bytes.to_vec(); + Ok(()) + })); + crate::menus::export_epub(&mut app, &json!({"path": "book.epub", "split": {"style": "Chapter"}})).unwrap(); + let bytes = written.lock().unwrap().clone(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("properties=\"cover-image\""), "the first page is the cover: {opf}"); + assert!(z.by_name("OEBPS/content2.xhtml").is_ok(), "the split override reaches the export"); + assert_eq!(app.session.active().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Off, "an override is not stored"); + crate::menus::export_epub(&mut app, &json!({"path": "book.epub", "cover": false})).unwrap(); + let bytes = written.lock().unwrap().clone(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(!opf.contains("cover-image"), "a caller's cover: false stays: {opf}"); + } + + #[test] + fn epub_export_options_store_split_document() { + use designcraft_doc::EpubSplit; + let mut app = test_app(); + app.session.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap(); + let undo_steps = |app: &crate::DesignApp| app.session.active().map(|st| st.history.undo.len()); + app.run("app.exportEpub", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.id.as_str(), d.b("split"), d.s("style").as_str()), ("epubExport", false, designcraft_doc::BASIC_PARAGRAPH)); + d.fields.insert("split".into(), json!(true)); + d.fields.insert("style".into(), json!("Chapter")); + let before = undo_steps(&app); + confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::Style("Chapter".into())); + assert_eq!(undo_steps(&app), before.map(|n| n + 1)); + open_epub_export(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.b("split"), d.s("mode"), d.s("style"))), Some((true, "style".into(), "Chapter".into()))); + confirm(&mut app).unwrap(); + assert_eq!(undo_steps(&app), before.map(|n| n + 1), "unchanged: no undo step"); + open_epub_export(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("exportTags")); + confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::ExportTags); + open_epub_export(&mut app); + cancel(&mut app); + assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::ExportTags, "Cancel stores nothing"); + } + #[test] fn pdf_export_defaults() { let d = Dialog::new("pdfExport", json!({})); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5e751fbe9..347e046de 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -43,7 +43,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), - ("app.exportEpub", "Export EPUB…", None, "{path?} — reflowable EPUB 3 (with the first page as its cover)"), + ( + "app.exportEpub", + "Export EPUB…", + None, + "{path?} — reflowable EPUB 3 (with the first page as its cover); without a path, EPUB Export Options (Split Document) and then the save dialog", + ), ( "app.exportInteractivePdf", "Export PDF (Interactive)…", @@ -1117,10 +1122,11 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option export_png(app, p), "app.exportIdml" => export_idml(app, p), + // From the menu, EPUB Export Options first; with a path, straight to the file. + "app.exportEpub" if p.get("path").is_some() => export_epub(app, p), "app.exportEpub" => { - let mut q = if p.is_object() { p.clone() } else { json!({}) }; - q["cover"] = json!(true); - export_bytes(app, &q, "epub", "file.exportEpub") + crate::dialogs::open_epub_export(app); + Ok(Value::Null) } "app.exportInteractivePdf" => { let mut q = if p.is_object() { p.clone() } else { json!({}) }; @@ -2110,6 +2116,16 @@ fn export_idml(app: &mut DesignApp, p: &Value) -> Result { /// Export EPUB / Text: ask for a path (default extension `ext`), run `cmd` without one and write /// what it returns (`base64` or `text`) with the platform writer. +/// File › Export EPUB…: export through `file.exportEpub` (the first page as its cover) to `path`, +/// else to a file the user picks. +pub(crate) fn export_epub(app: &mut DesignApp, p: &Value) -> Result { + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(o) = q.as_object_mut() { + o.entry("cover").or_insert(json!(true)); + } + export_bytes(app, &q, "epub", "file.exportEpub") +} + fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result { let st = app.session.active().ok_or("no document")?; let path = match p.get("path").and_then(Value::as_str) { @@ -2120,7 +2136,11 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result< } }; let Some(path) = path else { return Ok(Value::Null) }; - let mut params = json!({}); + // The caller's options go to the engine command; the path is ours. + let mut params = match p { + Value::Object(o) => Value::Object(o.iter().filter(|(k, _)| k.as_str() != "path").map(|(k, v)| (k.clone(), v.clone())).collect()), + _ => json!({}), + }; if cmd == "file.exportText" { let txt = path.to_ascii_lowercase().ends_with(".txt"); params["format"] = json!(if txt { "txt" } else { "rtf" }); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 197057ad2..28551d1a8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -400,7 +400,8 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. - View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups). - Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step). -- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML. +- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML. Its Split Document (EPUB only) flag marks the styles that start a new EPUB content file when the export splits on export tags. +- EPUB Export Options › Split Document (`document.epubSplit`, File › Export EPUB… dialog; `file.exportEpub {split}` for one export): off (one content file, the default), before each paragraph of a single paragraph style, or before each paragraph whose style has Split Document on in its Export Tagging. Navigation and note links point across the files. The setting is stored with the document, follows a renamed style, and round-trips through IDML (`EPubExportPreference`); Load Styles carries the per-style flag. - Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters. - Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text. - EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud.