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 01/13] 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 02/13] 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 03/13] 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 04/13] 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 7074dc8a856a4b63780fca721862570a193ed0bf Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 09:31:08 +0100 Subject: [PATCH 05/13] M6: preserve per-placement graphic layer options --- ROADMAP.md | 3 +- apps/designcraft-cli/src/perf.rs | 1 + crates/compose/src/tests.rs | 3 + crates/doc/src/assets.rs | 32 ++++++++ crates/doc/src/item.rs | 26 ++++++ crates/engine/src/cmd/datamerge/fill.rs | 1 + crates/engine/src/cmd/export.rs | 2 + crates/engine/src/cmd/file.rs | 3 + crates/engine/src/cmd/links.rs | 3 + crates/engine/src/cmd/mathexpr.rs | 1 + crates/engine/src/cmd/media.rs | 2 + crates/engine/src/cmd/preflight.rs | 22 +++++ crates/engine/src/cmd/table.rs | 1 + crates/engine/src/sample.rs | 1 + crates/engine/tests/graphic_layer_options.rs | 82 +++++++++++++++++++ crates/epub/src/lib.rs | 3 + crates/epub/tests/it/grouped_reading_order.rs | 1 + crates/format/src/lib.rs | 14 +++- crates/idml/src/export.rs | 26 ++++++ crates/idml/src/import.rs | 8 ++ crates/idml/src/tests.rs | 1 + crates/idml/src/tests/assets.rs | 71 ++++++++++++++++ crates/pdf/src/export.rs | 4 + crates/pdf/src/pdfx.rs | 1 + crates/pdf/src/tests.rs | 1 + .../support/image_content_cache_contract.rs | 1 + crates/pptx/src/tests.rs | 1 + crates/render/src/lib.rs | 2 + crates/render/tests/it/colorize.rs | 1 + crates/render/tests/it/placed_pdf.rs | 1 + crates/tools/src/tests.rs | 4 + crates/ui-egui/src/panels/properties.rs | 1 + docs/gaps.md | 4 +- docs/graphic-layer-options.md | 40 +++++++++ 34 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/graphic_layer_options.rs create mode 100644 docs/graphic-layer-options.md diff --git a/ROADMAP.md b/ROADMAP.md index 153555913..01c748d33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,10 @@ # DesignCraft roadmap **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) +- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md). - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 0c665f7e7..117adbc66 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -239,6 +239,7 @@ pub fn synthetic(spec: &Spec) -> Result { let (nw, nh) = (aw as f64, ah as f64); let s = (r.width() / nw).max(r.height() / nh); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3fc1c6ccb..85d73ecca 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -577,6 +577,7 @@ fn same_as_clipping_wrap_follows_the_clipping_path() { ); let triangle = PathData::single(SubPath::polyline(&[Point::new(75.0, 0.0), Point::new(150.0, 200.0), Point::new(0.0, 200.0)], true)); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (150.0, 200.0), xf: designcraft_geom::Affine::translate((150.0, 100.0)), @@ -725,6 +726,7 @@ fn an_image_contour_stops_at_its_frame() { let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, frame); // The image overhangs the frame on every side. it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(d.alloc()), size: (300.0, 250.0), xf: designcraft_geom::Affine::IDENTITY, @@ -769,6 +771,7 @@ fn a_clipping_path_contour_stops_at_its_frame() { true, )); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (150.0, 200.0), xf: designcraft_geom::Affine::translate((150.0, 100.0)), diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 0be5604c3..ed7679ee5 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -15,6 +15,37 @@ use crate::{Asset, AssetId, Content, Document, Item}; /// is treated as using every asset, so nothing it references is merged away or pruned. const MAX_DEPTH: usize = 64; +fn item_layer_options(it: &Item, depth: usize, overrides_only: bool) -> bool { + depth > MAX_DEPTH + || it.graphic().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides())) + || it.children().iter().any(|child| item_layer_options(child, depth + 1, overrides_only)) +} + +fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool { + depth > MAX_DEPTH + || st.objects.iter().any(|object| item_layer_options(&object.item, depth + 1, overrides_only)) + || st.tables.values().flat_map(|table| &table.cells).any(|cell| { + cell.graphic.as_ref().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides())) + || story_layer_options(&cell.text, depth + 1, overrides_only) + }) + || st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(¬e.text, depth + 1, overrides_only)) +} + +impl Document { + /// Whether saving needs a reader that retains per-placement graphic layer settings. + /// Exceeding the traversal budget conservatively requires the newer format. + pub fn has_graphic_layer_options(&self) -> bool { + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, false)) + || self.stories.values().any(|story| story_layer_options(story, 0, false)) + } + + /// Includes parent items, anchored objects, graphic cells and nested notes. + pub fn has_graphic_layer_visibility_overrides(&self) -> bool { + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) + || self.stories.values().any(|story| story_layer_options(story, 0, true)) + } +} + impl Asset { /// Whether `other` holds the same file: the same type, PDF page and crop, link and bytes. Files at /// different paths stay apart even with identical bytes, since each can be updated or @@ -356,6 +387,7 @@ mod tests { fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); it.content = Content::Graphic(Graphic { + layer_options: None, asset: AssetId(asset), size: (10.0, 10.0), xf: Affine::translate((crop, 0.0)), diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index cfb13dc53..9c4169fe4 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -596,10 +596,34 @@ pub enum Fitting { CenterContent, } +/// Retained IDML layer choices for one placement, independent of shared asset bytes. +/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy. +/// XML Self IDs are regenerated by the exporter and are not stored here. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphicLayerOptions { + pub attributes: std::collections::BTreeMap, + pub layers: Vec>, +} + +pub const GRAPHIC_LAYER_RENDERING_WARNING: &str = + "Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output"; + +impl GraphicLayerOptions { + pub fn has_visibility_overrides(&self) -> bool { + self.layers.iter().any(|layer| { + layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility")) + || ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true")) + }) + } +} + /// A placed graphic inside a frame. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Graphic { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_options: Option, pub asset: AssetId, /// Natural size of the graphic in points (pixels at its resolution, or PDF page size). pub size: (f64, f64), @@ -1642,6 +1666,7 @@ mod tests { #[test] fn graphics_without_a_clipping_path_load_and_save_as_before() { let g = Graphic { + layer_options: None, asset: AssetId(3), size: (40.0, 30.0), xf: Affine::translate((5.0, 5.0)), @@ -1653,6 +1678,7 @@ mod tests { fill: Default::default(), }; let v = serde_json::to_value(&g).unwrap(); + assert!(v.get("layerOptions").is_none(), "legacy graphics keep their existing representation"); assert!(v.get("clip").is_none(), "{v}"); assert_eq!(serde_json::from_value::(v.clone()).unwrap(), g); // A clip written with only its type and path gets the default options. diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index d4b2235e9..9547830a7 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -369,6 +369,7 @@ fn place_image( }; let mode = fitting_of(&options.fitting); let mut g = Graphic { + layer_options: None, asset: AssetId(0), size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 551dd7484..3797a9bf6 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -434,6 +434,7 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp ); let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w as f64, h as f64), xf: designcraft_geom::Affine::translate((r.x0, r.y0)) @@ -604,6 +605,7 @@ fn rasterize_where( ); let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); img_item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w as f64, h as f64), xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bc6928362..39b92a942 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -710,6 +710,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); validate_placement((bw, bh), r, xf, "file.place")?; it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (bw, bh), xf, @@ -736,6 +737,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { it.effects = e.clone(); } it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (bw, bh), xf, @@ -900,6 +902,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { validate_placement((nw, nh), frame_rect, xf, "place.drop")?; let r = s.edit(|d, sel| { let graphic = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (nw, nh), xf, diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c39e05668..83fe16bf1 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1128,6 +1128,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: sid, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1173,6 +1174,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: missing, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1209,6 +1211,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: missing, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index b684f2457..e5342f79c 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -19,6 +19,7 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w, h), xf: Affine::translate(at), diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index 9e26fb0b4..a4e3f5ae6 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -32,6 +32,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; let r = it.inner_bounds(); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (r.width(), r.height()), xf: Affine::translate((r.x0, r.y0)), @@ -52,6 +53,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let r = super::finite_rect("file.place", Rect::new(x, y, x + w, y + h))?; let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w, h), xf: Affine::translate((x, y)), diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 19d64c1fc..c69050ace 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -249,6 +249,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { let xf = parent * it.xf; pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf))); let page = page_of(d, SpreadRef::Doc(si), it, parent); + if let Content::Graphic(g) = &it.content + && g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) + { + out.push(Issue { + severity: "warning", + kind: "unsupportedGraphicLayers", + message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(), + item: Some(it.id.0), + page, + }); + } match &it.content { Content::Graphic(g) => match d.assets.get(&g.asset) { None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }), @@ -307,6 +318,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } } } + // Nested graphic cells and anchored/parent items may not have a page frame + // in the walk above. Keep the warning visible after a native save/reopen. + if !out.iter().any(|issue| issue.kind == "unsupportedGraphicLayers") && d.has_graphic_layer_visibility_overrides() { + out.push(Issue { + severity: "warning", + kind: "unsupportedGraphicLayers", + message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(), + item: None, + page: None, + }); + } out } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 776dd5e82..92b46fa77 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -2017,6 +2017,7 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?; cell.graphic = Some(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (nw, nh), xf, diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 0009b4adb..a77cb1d7d 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -79,6 +79,7 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: ( let (nw, nh) = (px.0 as f64, px.1 as f64); let k = (r.width() / nw).max(r.height() / nh); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k), diff --git a/crates/engine/tests/graphic_layer_options.rs b/crates/engine/tests/graphic_layer_options.rs new file mode 100644 index 000000000..8699a4a66 --- /dev/null +++ b/crates/engine/tests/graphic_layer_options.rs @@ -0,0 +1,82 @@ +//! Per-placement artwork layer metadata must survive native saves and stay visibly unsupported. +use std::io::{Cursor, Read, Write}; + +use designcraft_doc::{Content, GRAPHIC_LAYER_RENDERING_WARNING}; +use designcraft_engine::{DocState, Session}; +use serde_json::json; + +fn fixture() -> Vec { + let xml = r#" + + + + + + + + + "#; + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(xml.as_bytes()).unwrap(); + z.finish().unwrap().into_inner() +} + +#[test] +fn graphic_layer_options_survive_native_reopen_and_report_unsupported_output() { + let imported = designcraft_idml::import_idml_report(&fixture(), &|_| None).unwrap(); + assert!(imported.warnings.iter().any(|w| w.contains(GRAPHIC_LAYER_RENDERING_WARNING))); + let doc = imported.document; + let options = |d: &designcraft_doc::Document| { + d.spreads[0] + .items + .iter() + .filter_map(|i| match &i.content { + Content::Graphic(g) => g.layer_options.clone(), + _ => None, + }) + .collect::>() + }; + let expected = options(&doc); + let saved = designcraft_format::save(&doc).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + assert_eq!(serde_json::from_str::(&meta).unwrap()["version"], 4, "old readers must not silently drop the settings"); + let reopened = designcraft_format::load(&saved).unwrap(); + assert_eq!(options(&reopened), expected); + let mut s = Session::new(); + s.add_document(DocState::new(reopened, None)); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); + let pdf = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(pdf["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING)); +} + +#[test] +fn graphic_layer_options_in_cells_require_the_new_native_reader() { + let mut doc = designcraft_idml::import_idml(&fixture()).unwrap(); + let graphic = doc.spreads[0] + .items + .iter() + .find_map(|i| match &i.content { + Content::Graphic(g) => Some(g.clone()), + _ => None, + }) + .unwrap(); + std::sync::Arc::make_mut(&mut doc.spreads[0]).items.clear(); + let layer = doc.default_layer(); + let (_, story) = doc + .add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()) + .unwrap(); + let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0); + table.cells[0].graphic = Some(graphic); + doc.story_mut(story).unwrap().insert_table(0, table); + assert!(doc.has_graphic_layer_options()); + let reopened = designcraft_format::load(&designcraft_format::save(&doc).unwrap()).unwrap(); + assert!(reopened.has_graphic_layer_visibility_overrides()); + let mut s = Session::new(); + s.add_document(DocState::new(reopened, None)); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 348b9c80e..d0be6713a 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -1531,6 +1531,7 @@ mod tests { let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); item.alt_text = alt.into(); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, @@ -1939,6 +1940,7 @@ mod tests { let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); item.alt_text = alt.into(); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, @@ -2055,6 +2057,7 @@ mod tests { let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); item.alt_text = "Cover \"A\" & B".into(); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, diff --git a/crates/epub/tests/it/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs index fe2223091..42811f61d 100644 --- a/crates/epub/tests/it/grouped_reading_order.rs +++ b/crates/epub/tests/it/grouped_reading_order.rs @@ -100,6 +100,7 @@ fn nested_and_item_rotations_are_combined_before_measuring_graphics() { let image = ItemId(doc.alloc()); let mut item = Item::new(image, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 10.0))); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5e87c5408..4a3144f08 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -22,8 +22,8 @@ use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; /// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; -/// version 3 adds rendering of retained EPS source assets. -pub const VERSION: u32 = 3; +/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer options. +pub const VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -87,7 +87,14 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en // Older readers would silently omit EPS artwork. Preview-only legacy assets and // documents without EPS stay at 2 so their existing readers keep working; writing 1 // would make a reader re-run the version 2 paragraph upgrade over current defaults. - let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 2 }; + // Older readers ignore unknown Graphic fields and would silently drop layer choices. + let version = if doc.has_graphic_layer_options() { + 4 + } else if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { + 3 + } else { + 2 + }; let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; if let Some(png) = preview { @@ -404,6 +411,7 @@ mod tests { let id = ItemId(d.alloc()); let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0))); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (1.0, 1.0), xf: Affine::translate((crop, crop)), diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index c174c1889..fa25914ad 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -20,6 +20,14 @@ const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents"; const TOC_STYLE_NAME: &str = "Table of Contents"; const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; +// Retained IDML attributes have unprefixed ASCII names. Native JSON can be +// edited independently: never let its map keys become XML syntax or Self IDs. +fn graphic_layer_attribute_name(key: &str) -> bool { + !key.is_empty() + && key != "Self" + && key.bytes().enumerate().all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && (b.is_ascii_digit() || matches!(b, b'.' | b'-')))) +} + /// Export options. #[derive(Clone, Debug)] pub struct ExportOptions { @@ -2061,6 +2069,24 @@ impl<'a> Ex<'a> { if let Some(c) = &g.clip { el.push(clipping_settings(c)); } + if let Some(options) = &g.layer_options { + let mut option = El::new("GraphicLayerOption"); + for (key, value) in &options.attributes { + if graphic_layer_attribute_name(key) { + option.set(key, value); + } + } + for attributes in &options.layers { + let mut layer = El::new("GraphicLayer").attr("Self", self.fresh()); + for (key, value) in attributes { + if graphic_layer_attribute_name(key) { + layer.set(key, value); + } + } + option.push(layer); + } + el.push(option); + } let uri = match &asset.link { Some(l) => path_to_uri(l), None => format!("file:{}", asset.name), diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index fed9f49b9..9e1c01520 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3043,6 +3043,13 @@ impl<'r> Importer<'r> { } fn graphic(&mut self, g: &El) -> Content { + let layer_options = g.find("GraphicLayerOption").map(|options| { + let attrs = |e: &El| e.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect(); + designcraft_doc::GraphicLayerOptions { attributes: attrs(options), layers: options.find_all("GraphicLayer").map(attrs).collect() } + }); + if layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) { + self.warnings.push(format!("graphic {}: {}", g.get("Self").unwrap_or("(unnamed)"), designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING)); + } let gxf = parse_affine(g.get("ItemTransform")); let (l, t, r, b) = match g.prop_el("GraphicBounds") { Some(gb) => { @@ -3059,6 +3066,7 @@ impl<'r> Importer<'r> { let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path); let graphic = |asset, size| { Content::Graphic(designcraft_doc::Graphic { + layer_options: layer_options.clone(), asset, size, xf: gxf * Affine::translate((l, t)), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b72ba1c86..51c362dce 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4453,6 +4453,7 @@ fn exports_graphic_in_table_cell() { }); let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0); t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic { + layer_options: None, asset: id, size: (24.0, 24.0), xf: designcraft_geom::Affine::IDENTITY, diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 23ec31f34..9372c3adf 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -70,6 +70,77 @@ fn package(frames: &[String]) -> Vec { ]) } +#[test] +fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() { + let option = |n: usize, visible: &str| { + format!( + r#""# + ) + }; + let frames = ["false", "true"] + .into_iter() + .enumerate() + .map(|(i, visible)| { + let n = i + 1; + frame(n, i as f64 * 100.0, 0.0, &image(n, PHOTO, None).replace("", &format!("{}", option(n, visible)))) + }) + .collect::>(); + let reads = Cell::new(0); + let loaded = import_idml_report_with_previews( + &package(&frames), + &|_| { + reads.set(reads.get() + 1); + Some(vec![42; 16]) + }, + &Default::default(), + ) + .unwrap(); + assert_eq!(reads.get(), 1); + assert_eq!(loaded.document.assets.len(), 1, "layer choices belong to placements, not shared image bytes"); + let out = export_idml(&loaded.document); + let spreads = zip_text(&out, "Spreads/"); + assert_eq!(spreads.matches(" g.layer_options.clone(), + _ => None, + }) + .collect::>() + }; + assert_eq!(options(&loaded.document), options(&reopened)); + let twice = zip_text(&export_idml(&reopened), "Spreads/"); + assert_eq!(twice.matches(" Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> { let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 5d3ad0644..7df892abf 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1381,6 +1381,9 @@ 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) { + if g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) { + self.warn(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING); + } 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 }); @@ -2175,6 +2178,7 @@ mod eps_tests { designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)), ); item.content = Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (2.0, 1.0), xf: Affine::translate((72.0, 72.0)) * Affine::scale(100.0), diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 11d30602b..5566353fd 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -496,6 +496,7 @@ mod tests { let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0); let mut it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size, xf: Affine::translate(at) * Affine::scale(100.0 / size.0), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 39f472ffc..663ef9169 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -257,6 +257,7 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) { let id = ItemId(d.alloc()); let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset, size: (px.0 as f64, px.1 as f64), xf: designcraft_geom::Affine::translate((72.0, 72.0)) * designcraft_geom::Affine::scale(50.0), diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 2442c33a0..a35fc464e 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -30,6 +30,7 @@ fn place_named(d: &mut Document, data: Vec, x: f64, name: &str, mime: &str, let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0))); item.stroke.weight = 0.0; item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (20.0, 20.0), xf: Affine::translate((x, 36.0)), diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs index ba97a67b8..253c5d260 100644 --- a/crates/pptx/src/tests.rs +++ b/crates/pptx/src/tests.rs @@ -212,6 +212,7 @@ fn pictures_are_cropped_to_their_frames() { it.fill = Fill::none(); // 200 × 100 image at 1:1, its left quarter cut off by the frame. it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (200.0, 100.0), xf: Affine::translate((50.0, 100.0)), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 522e245cb..d9b527a5e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1984,6 +1984,7 @@ mod tests { let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (100.0, 100.0), xf: Affine::translate((100.0, 100.0)), @@ -2038,6 +2039,7 @@ mod tests { let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (100.0, 100.0), xf: Affine::translate((100.0, 100.0)), diff --git a/crates/render/tests/it/colorize.rs b/crates/render/tests/it/colorize.rs index 883b36b8e..5bf87a54d 100644 --- a/crates/render/tests/it/colorize.rs +++ b/crates/render/tests/it/colorize.rs @@ -51,6 +51,7 @@ fn shown(file: Vec, fill: Fill) -> Vec<[u8; 4]> { it.fill = Fill::swatch(CYAN); it.stroke = Stroke::default(); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (150.0, 50.0), xf: Affine::translate((100.0, 100.0)), diff --git a/crates/render/tests/it/placed_pdf.rs b/crates/render/tests/it/placed_pdf.rs index 118453067..deb699e11 100644 --- a/crates/render/tests/it/placed_pdf.rs +++ b/crates/render/tests/it/placed_pdf.rs @@ -78,6 +78,7 @@ fn placed(size: f64, pdf: Vec) -> Rendered { let id = ItemId(d.alloc()); let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size))); item.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (size, size), xf: Affine::IDENTITY, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..34bc40fc6 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -27,6 +27,7 @@ fn color_theme_tool_requests_a_palette_without_editing_the_image() { let mut d = Document::new(&NewDocument::default()); let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1108,6 +1109,7 @@ fn graphic_frame(doc: &mut Document) -> ItemId { let id = ItemId(doc.alloc()); let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (200.0, 100.0), xf: designcraft_geom::Affine::translate((50.0, 100.0)), @@ -1502,6 +1504,7 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let photo = ItemId(d.alloc()); let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::translate((0.0, 0.0)), @@ -1855,6 +1858,7 @@ fn eyedropper_ignores_inactive_states_of_a_multi_state_object() { let raster = { let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::translate((0.0, 0.0)), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 0422936e3..58cf42b24 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2943,6 +2943,7 @@ mod tests { }), ); doc.item_mut(id).unwrap().content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (72.0, 72.0), xf: designcraft_geom::Affine::IDENTITY, diff --git a/docs/gaps.md b/docs/gaps.md index 6723fe1e7..c9221039b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -202,6 +202,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 20–35 h. ### 19. Placed graphics depth (FF) +- **Have:** per-placement IDML graphic-layer settings survive IDML/native round trips; native format 4 guards older readers. Unsupported layer visibility is reported by import, preflight and PDF export. See [graphic-layer-options.md](graphic-layer-options.md). +- **Still missing:** applying the retained layer visibility to the rendered/PDF appearance; layer-comp choices and the layer-options UI. Metadata preservation does not establish visual fidelity. - **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation references, redefining the existing catalog rather than choosing an arbitrary object number. Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim. diff --git a/docs/graphic-layer-options.md b/docs/graphic-layer-options.md new file mode 100644 index 000000000..953995839 --- /dev/null +++ b/docs/graphic-layer-options.md @@ -0,0 +1,40 @@ +# Placed graphic layer options + +IDML `GraphicLayerOption` / `GraphicLayer` settings are retained on each +`Graphic.layer_options`, not on the shared asset. Two placements of one linked +file can therefore keep different layer choices while sharing image bytes. +Scalar option/layer attributes, layer order, layer IDs/names, original/current +visibility, link-update policy and view/export/print flags survive import, +native save/reopen and IDML export. XML Self IDs are regenerated. Native-edited +attribute maps cannot inject XML syntax or override those Self IDs. + +This is preservation, **not layer-aware rendering**. The current renderer and +PDF exporter use the asset's composite/default appearance and do not apply +these per-placement choices. Changed visibility and explicit view/export/print +states produce an import warning, a persistent `unsupportedGraphicLayers` +preflight issue, and a PDF export warning. Nested graphic cells, anchored +objects and parent items remain detectable after native reopening. + +Native files containing these settings require format version 4. Earlier +readers reject that version instead of loading and silently discarding the new +field. Documents without layer settings retain their existing version 2/3 +requirements. Older graphics without `layerOptions` still deserialize with +`None`, and their JSON representation is unchanged. + +Synthetic regressions cover independent overrides on two placements sharing +one asset, metadata round trips, native reader requirements, nested cells, +preflight/PDF warnings and invalid native attribute names. Private originals +and artwork are not repository fixtures; any collection results are local +metadata checks, not an independent reference-rendering or fidelity oracle. + +Local collection check (2026-10-23): a five-document pilot passed before +expansion to 205 previously converted documents. All 205 completed IDML → +native save/reopen → IDML; all 1,113 layer records across 68 documents matched +their input scalar attributes and order, ignoring regenerated Self IDs. +Persistent preflight warnings appeared in the 57 documents with overrides. +The private source files, layer names, document paths and per-file logs remain +outside this repository. Original INDD files were not opened by this check. + +Remaining work: apply visibility to PSD layers/PDF optional content, implement +layer-comp choices and a layer-options UI, and validate appearance against +verified same-save reference PDFs. Gap 19 remains open. From 3d362d0bdfda036ac43d3e6b51b18d6aff03c386 Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 10:42:21 +0100 Subject: [PATCH 06/13] M6: preserve nested graphic layers and retain export diagnostics --- crates/doc/src/assets.rs | 6 +++ crates/doc/src/item.rs | 3 ++ crates/engine/src/cmd/export.rs | 4 ++ crates/engine/src/cmd/preflight.rs | 2 +- crates/engine/tests/graphic_layer_options.rs | 21 +++++++++-- crates/idml/src/export.rs | 26 ++++++++++++- crates/idml/src/import.rs | 15 +++++++- crates/idml/src/tests/assets.rs | 39 +++++++++++++++++++- docs/graphic-layer-options.md | 15 ++++++-- 9 files changed, 119 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index ed7679ee5..872d15ba8 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -39,6 +39,12 @@ impl Document { || self.stories.values().any(|story| story_layer_options(story, 0, false)) } + /// Placements outside the ordinary document-spread frame walk used by preflight. + pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool { + self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) + || self.stories.values().any(|story| story_layer_options(story, 0, true)) + } + /// Includes parent items, anchored objects, graphic cells and nested notes. pub fn has_graphic_layer_visibility_overrides(&self) -> bool { self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 9c4169fe4..586358f1d 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -604,6 +604,9 @@ pub enum Fitting { pub struct GraphicLayerOptions { pub attributes: std::collections::BTreeMap, pub layers: Vec>, + /// Parent indices in pre-order; absent entries are top-level layers (older native files). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parents: Vec>, } pub const GRAPHIC_LAYER_RENDERING_WARNING: &str = diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 3797a9bf6..c2ca1e317 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -745,6 +745,10 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { let doc: &designcraft_doc::Document = &boxed; let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let mut r = r; + // Flattening and effect rasterization replace placements, so inspect the source too. + if st.doc.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { + r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into()); + } // Transparency Blend Space: the page group of pages with transparency. let spreads = designcraft_pdf::sheet_spreads(doc, &opts); let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index c69050ace..a0c1a6da1 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -320,7 +320,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } // Nested graphic cells and anchored/parent items may not have a page frame // in the walk above. Keep the warning visible after a native save/reopen. - if !out.iter().any(|issue| issue.kind == "unsupportedGraphicLayers") && d.has_graphic_layer_visibility_overrides() { + if d.has_unwalked_graphic_layer_visibility_overrides() { out.push(Issue { severity: "warning", kind: "unsupportedGraphicLayers", diff --git a/crates/engine/tests/graphic_layer_options.rs b/crates/engine/tests/graphic_layer_options.rs index 8699a4a66..117517002 100644 --- a/crates/engine/tests/graphic_layer_options.rs +++ b/crates/engine/tests/graphic_layer_options.rs @@ -13,7 +13,7 @@ fn fixture() -> Vec { - + "#; let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); @@ -64,7 +64,7 @@ fn graphic_layer_options_in_cells_require_the_new_native_reader() { _ => None, }) .unwrap(); - std::sync::Arc::make_mut(&mut doc.spreads[0]).items.clear(); + // Keep the original spread graphic too: its warning must not hide the cell warning. let layer = doc.default_layer(); let (_, story) = doc .add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()) @@ -78,5 +78,20 @@ fn graphic_layer_options_in_cells_require_the_new_native_reader() { let mut s = Session::new(); s.add_document(DocState::new(reopened, None)); let preflight = s.execute("preflight.run", &json!({})).unwrap(); - assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); + let warnings: Vec<_> = preflight["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedGraphicLayers").collect(); + assert!(warnings.iter().any(|i| i["item"].is_null()), "the cell needs an independent document-level warning"); + assert!(warnings.iter().any(|i| !i["item"].is_null()), "the spread retains its item warning"); +} + +#[test] +fn pdf_layer_warning_survives_soft_effects_and_flattening() { + for flatten in [false, true] { + let doc = designcraft_idml::import_idml(&fixture()).unwrap(); + let id = doc.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().id.0; + let mut session = Session::new(); + session.add_document(DocState::new(doc, None)); + session.execute("object.feather", &json!({"ids":[id], "width":2})).unwrap(); + let out = session.execute("file.exportPdf", &json!({"flatten":flatten})).unwrap(); + assert!(out["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING), "{out}"); + } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index fa25914ad..0e338c4b6 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -25,6 +25,7 @@ const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; fn graphic_layer_attribute_name(key: &str) -> bool { !key.is_empty() && key != "Self" + && key != "xmlns" && key.bytes().enumerate().all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && (b.is_ascii_digit() || matches!(b, b'.' | b'-')))) } @@ -2076,13 +2077,36 @@ impl<'a> Ex<'a> { option.set(key, value); } } - for attributes in &options.layers { + // Parent indices must precede their children. Ignore invalid parents and bound DOM depth + // even when a native file has been edited independently of the IDML importer. + let mut depths = Vec::with_capacity(options.layers.len()); + let mut parents = Vec::with_capacity(options.layers.len()); + for i in 0..options.layers.len() { + let parent = options.parents.get(i).copied().flatten().filter(|p| *p < i && depths.get(*p).is_some_and(|d| *d < 240)); + depths.push(parent.and_then(|p| depths.get(p)).copied().unwrap_or(0) + 1); + parents.push(parent); + } + let mut children: Vec> = vec![Vec::new(); options.layers.len()]; + let mut roots = Vec::new(); + for (i, attributes) in options.layers.iter().enumerate().rev() { let mut layer = El::new("GraphicLayer").attr("Self", self.fresh()); for (key, value) in attributes { if graphic_layer_attribute_name(key) { layer.set(key, value); } } + if let Some(nested) = children.get_mut(i) { + for child in std::mem::take(nested).into_iter().rev() { + layer.push(child); + } + } + if let Some(nested) = parents.get(i).copied().flatten().and_then(|parent| children.get_mut(parent)) { + nested.push(layer); + } else { + roots.push(layer); + } + } + for layer in roots.into_iter().rev() { option.push(layer); } el.push(option); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 9e1c01520..3db03576f 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3045,7 +3045,20 @@ impl<'r> Importer<'r> { fn graphic(&mut self, g: &El) -> Content { let layer_options = g.find("GraphicLayerOption").map(|options| { let attrs = |e: &El| e.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect(); - designcraft_doc::GraphicLayerOptions { attributes: attrs(options), layers: options.find_all("GraphicLayer").map(attrs).collect() } + let mut out = designcraft_doc::GraphicLayerOptions { attributes: attrs(options), ..Default::default() }; + let mut pending: Vec<_> = options.find_all("GraphicLayer").map(|e| (e, None)).collect(); + pending.reverse(); + while let Some((layer, parent)) = pending.pop() { + let index = out.layers.len(); + out.layers.push(attrs(layer)); + out.parents.push(parent); + let children: Vec<_> = layer.find_all("GraphicLayer").collect(); + pending.extend(children.into_iter().rev().map(|e| (e, Some(index)))); + } + if out.parents.iter().all(Option::is_none) { + out.parents.clear(); + } + out }); if layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) { self.warnings.push(format!("graphic {}: {}", g.get("Self").unwrap_or("(unnamed)"), designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING)); diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 9372c3adf..71c01ff0b 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -70,6 +70,23 @@ fn package(frames: &[String]) -> Vec { ]) } +#[test] +fn nested_graphic_layers_preserve_hierarchy_on_roundtrip() { + let option = r#" + + + "#; + let image = image(1, PHOTO, None).replace("", &format!("{option}")); + let d = import_idml(&package(&[frame(1, 0.0, 0.0, &image)])).unwrap(); + let options = |d: &Document| d.spreads[0].items[0].graphic().unwrap().layer_options.clone().unwrap(); + let expected = options(&d); + assert_eq!(expected.layers.len(), 3); + assert_eq!(expected.parents, [None, Some(0), None]); + assert!(expected.has_visibility_overrides()); + let reopened = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(options(&reopened), expected); +} + #[test] fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() { let option = |n: usize, visible: &str| { @@ -121,6 +138,23 @@ fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() { assert_eq!(twice.matches(" Date: Fri, 23 Oct 2026 19:23:13 +0100 Subject: [PATCH 07/13] M6: retain layer order when native parents reopen closed subtrees --- ROADMAP.md | 2 ++ crates/idml/src/export.rs | 18 ++++++++++++------ crates/idml/src/tests/assets.rs | 17 +++++++++++++++++ docs/graphic-layer-options.md | 2 +- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 01c748d33..f361831d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,8 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-23** · IDML graphic-layer export rejects parents in already closed subtrees, + preserving native-edited layer order through round trips. Synthetic regression added. - **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported all-content crops retain the whole page rather than silently clipping hidden layers. diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0e338c4b6..77381a215 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2077,14 +2077,20 @@ impl<'a> Ex<'a> { option.set(key, value); } } - // Parent indices must precede their children. Ignore invalid parents and bound DOM depth - // even when a native file has been edited independently of the IDML importer. - let mut depths = Vec::with_capacity(options.layers.len()); + // Only an ancestor whose subtree is still open can parent the next layer. + // Reopening a closed subtree would reorder native-edited lists on roundtrip. + let mut open = Vec::new(); let mut parents = Vec::with_capacity(options.layers.len()); for i in 0..options.layers.len() { - let parent = options.parents.get(i).copied().flatten().filter(|p| *p < i && depths.get(*p).is_some_and(|d| *d < 240)); - depths.push(parent.and_then(|p| depths.get(p)).copied().unwrap_or(0) + 1); - parents.push(parent); + let parent = options.parents.get(i).copied().flatten(); + if let Some(at) = parent.and_then(|p| open.iter().position(|id| *id == p)).filter(|at| *at < 239) { + open.truncate(at + 1); + parents.push(parent); + } else { + open.clear(); + parents.push(None); + } + open.push(i); } let mut children: Vec> = vec![Vec::new(); options.layers.len()]; let mut roots = Vec::new(); diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 71c01ff0b..48875c256 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -155,6 +155,23 @@ fn malformed_native_graphic_layer_parents_cannot_cycle_or_overflow_xml_depth() { assert_eq!(reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap().layers.len(), 300); } +#[test] +fn closed_graphic_layer_subtrees_cannot_reorder_native_layers() { + let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap(); + let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else { + panic!("graphic") + }; + g.layer_options = Some(designcraft_doc::GraphicLayerOptions { + layers: ["A", "B", "C", "D"].into_iter().map(|name| std::collections::BTreeMap::from([("Name".into(), name.into())])).collect(), + parents: vec![None, None, Some(0), Some(2)], + ..Default::default() + }); + let reopened = import_idml(&export_idml(&d)).unwrap(); + let options = reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap(); + assert_eq!(options.layers.iter().map(|a| a["Name"].as_str()).collect::>(), ["A", "B", "C", "D"]); + assert_eq!(options.parents, [None, None, None, Some(2)]); +} + #[test] fn graphic_layer_attributes_from_native_data_cannot_inject_xml_or_self_ids() { let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap(); diff --git a/docs/graphic-layer-options.md b/docs/graphic-layer-options.md index 7668f42f3..53a527991 100644 --- a/docs/graphic-layer-options.md +++ b/docs/graphic-layer-options.md @@ -8,7 +8,7 @@ visibility, link-update policy and view/export/print flags survive import, native save/reopen and IDML export. XML Self IDs are regenerated. Native-edited attribute maps cannot inject XML syntax, declare namespaces or override those Self IDs. Parent indices are optional in native files; missing entries retain the older -flat representation. Invalid/cyclic parent indices and over-budget nesting +flat representation. Invalid/cyclic parent indices, parents in closed subtrees and over-budget nesting are emitted as top-level layers, retaining their scalar metadata without unbounded XML recursion. From 2633fe8b5dded65f7faeaf666de93b7668fcaa14 Mon Sep 17 00:00:00 2001 From: Andee Collard Date: Sat, 24 Oct 2026 13:15:46 +0100 Subject: [PATCH 08/13] Format: choose the native version in one place; share v4 with #1671 skylartaylor's review: #1647 and #1671 each introduced native format 4 for a different capability, so a reader that understood only one could accept a file carrying the other and silently drop its settings. No release has shipped v4 (main and v0.12.0 still write at most 3), so v4 is defined as one capability covering both retained placed-graphic layer options and explicit all-on PDF layer choices; #1671 is stacked on this branch. Move the version choice into required_version so every new capability is decided in one place, document the shared v4 contract on VERSION, and test that plain, EPS-only and layer-option documents keep 2, 3 and 4 respectively. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/lib.rs | 49 ++++++++++++++++++++++++++--------- docs/graphic-layer-options.md | 5 ++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4a3144f08..d119c3538 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -22,7 +22,10 @@ use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; /// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; -/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer options. +/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer +/// options. Version 4 is one capability shared with explicit all-on placed-PDF layer choices +/// (storytold/designcraft#1671): it must not be released until a reader understands both, and any +/// later capability needs version 5. [`required_version`] is the only place that picks a version. pub const VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] @@ -67,6 +70,19 @@ pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result u32 { + // Older readers ignore unknown Graphic fields and would silently drop layer choices. + if doc.has_graphic_layer_options() { + return 4; + } + // Older readers would silently omit EPS artwork. Preview-only legacy assets and documents + // without EPS stay at 2 so their existing readers keep working; writing 1 would make a + // reader re-run the version 2 paragraph upgrade over current defaults. + if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { 3 } else { 2 } +} + fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_entry: usize) -> Result, FormatError> { // JSON writes infinity and NaN as null, which no load accepts: the file would not open. if let Some(id) = doc.non_finite_item() { @@ -84,17 +100,7 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en z.start_file("mimetype", stored).map_err(io)?; z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("meta.json", deflate).map_err(io)?; - // Older readers would silently omit EPS artwork. Preview-only legacy assets and - // documents without EPS stay at 2 so their existing readers keep working; writing 1 - // would make a reader re-run the version 2 paragraph upgrade over current defaults. - // Older readers ignore unknown Graphic fields and would silently drop layer choices. - let version = if doc.has_graphic_layer_options() { - 4 - } else if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { - 3 - } else { - 2 - }; + let version = required_version(doc); let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; if let Some(png) = preview { @@ -481,6 +487,25 @@ mod tests { assert_eq!(version(&d), 2, "legacy EPS previews stay compatible"); } + #[test] + fn one_place_picks_the_oldest_reader_that_keeps_the_document() { + use designcraft_doc::{Content, GraphicLayerOptions}; + let mut d = Document::new(&NewDocument::default()); + assert_eq!(required_version(&d), 2, "plain documents keep the version 2 reader"); + let aid = AssetId(d.alloc()); + let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec(); + d.assets.insert( + aid, + Arc::new(Asset { id: aid, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(eps), ..Default::default() }), + ); + let id = place(&mut d, aid, 0.0); + assert_eq!(required_version(&d), 3, "EPS sources alone keep the version 3 reader"); + let Some(Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) else { panic!("placed graphic") }; + g.layer_options = Some(GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() }); + assert_eq!(required_version(&d), 4, "retained graphic layer options need version 4 even with EPS"); + assert_eq!(required_version(&load(&save(&d).unwrap()).unwrap()), 4); + } + #[test] fn roundtrip_with_assets() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); diff --git a/docs/graphic-layer-options.md b/docs/graphic-layer-options.md index 53a527991..f38d82d34 100644 --- a/docs/graphic-layer-options.md +++ b/docs/graphic-layer-options.md @@ -27,6 +27,11 @@ field. Documents without layer settings retain their existing version 2/3 requirements. Older graphics without `layerOptions` still deserialize with `None`, and their JSON representation is unchanged. +Version 4 is a single reader capability shared with explicit all-on placed-PDF +layer choices (#1671): a version 4 reader must understand both, so the two +changes ship in the same release. One function in the format crate, +`required_version`, chooses the version for every save. + Synthetic regressions cover independent overrides on two placements sharing one asset, metadata round trips, native reader requirements, nested cells, nested layer hierarchy, preflight/PDF warnings after rasterization and flattening, 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 09/13] 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 10/13] 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. From a32ad396f3c3516c8df242a592d87752ab9f407d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 15:37:10 -0600 Subject: [PATCH 11/13] PDF: give the test-only Graphic literals the layer_options field Graphic gained an optional layer_options field, so the test-only literals in the PDF crate and in the engine's optional-content support need it. No behaviour changes. --- crates/engine/tests/support/optional_content.rs | 1 + crates/pdf/src/export.rs | 1 + crates/pdf/src/registration_tests.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/engine/tests/support/optional_content.rs b/crates/engine/tests/support/optional_content.rs index 3ed73d74f..b3acb3511 100644 --- a/crates/engine/tests/support/optional_content.rs +++ b/crates/engine/tests/support/optional_content.rs @@ -115,6 +115,7 @@ pub fn document(bytes: Vec, hidden_sets: &[&[&str]]) -> Document { crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), + layer_options: None, }); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dd59ff418..d06fdde0c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -2009,6 +2009,7 @@ mod image_content_cache_tests { crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), + layer_options: None, }; ex.graphic_unclipped(&mut surface, &graphic, hidden.as_deref()); assert!(ex.layer_error.is_none()); diff --git a/crates/pdf/src/registration_tests.rs b/crates/pdf/src/registration_tests.rs index 278100304..d3d76f961 100644 --- a/crates/pdf/src/registration_tests.rs +++ b/crates/pdf/src/registration_tests.rs @@ -17,6 +17,7 @@ fn placed_document(source: Vec) -> Document { crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), + layer_options: None, }); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); doc From a59880394d618ff69cd9f3fefdd8b7cf56d1619d Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:23:48 -0400 Subject: [PATCH 12/13] Search: Delete Workspace is left out of Quick Apply and command search (#1791) * Search: Delete Workspace is left out of Quick Apply and command search It needs a saved workspace's name, which a search entry can't supply, so choosing it there failed. The application bar's workspace menu lists the saved workspaces to delete. * Search: leave out the interface commands that fail or reset a setting there Quick Apply and the application bar's search ran their entries without parameters. Commands that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file, or a swatch or color group failed when chosen there. Interface Color Theme and UI Scaling switched to the dark theme and 100%, Workspace switched to Essentials, and Reset Shortcuts cleared every custom shortcut without asking. Search leaves all of them out; each stays in the menu, panel or dialog that gives it the value. * Search: the flattener preset, Screen Mode, Zoom To and the Task Bar pin stay out of Quick Apply --- ROADMAP.md | 1 + crates/ui-egui/src/menus.rs | 140 +++++++++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4337c28d1..541a56e5d 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** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **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/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 347e046de..073edc238 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3369,13 +3369,53 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str, cjk out } +/// UI commands search leaves out: they only run with a parameter search can't supply and have no +/// dialog to ask for it, or without one they reset a setting. Each stays in the menu, panel or +/// dialog that gives it the value (the workspace menus, Keyboard Shortcuts, Preferences…). +const NOT_SEARCHED: &[&str] = &[ + // The workspace to switch to or delete. + "window.workspace", + "window.deleteWorkspace", + // A command and its shortcut, a menu item, a shortcut set; Reset Shortcuts clears every + // custom shortcut, and the Keyboard Shortcuts dialog offers it. + "window.setShortcut", + "window.resetShortcuts", + "window.importShortcuts", + "window.hideMenuItem", + // Without a value these reset the interface to 100% and the dark theme. + "window.uiScale", + "window.brightness", + // A panel, and where it goes. + "window.floatPanel", + "window.dockPanel", + "window.panel.layout", + "window.panel.move", + "window.panel.float", + "window.panel.dock", + "window.panel.activate", + "window.panel.close", + "window.panel.closeGroup", + // A window edge, an app page, a recent file, a swatch or color group. + "window.resize", + "help.app", + "app.openRecent", + "app.swatchOptionsDialog", + "app.colorGroupOptionsDialog", + // Without a value these clear the flattener preset, go to Normal screen mode or 100% zoom. + "app.flattener", + "view.screenMode", + "view.zoom", + // Where to pin the Contextual Task Bar. + "window.taskBarPin", +]; + /// The commands Quick Apply and the application bar's search find: (id, label, default shortcut). fn quick_commands() -> impl Iterator)> { designcraft_engine::command_specs() .iter() .filter(|c| !c.menu.is_empty() || c.shortcut.is_some()) .map(|c| (c.id, c.label, c.shortcut)) - .chain(UI_COMMANDS.iter().map(|c| (c.0, c.1, c.2))) + .chain(UI_COMMANDS.iter().filter(|c| !NOT_SEARCHED.contains(&c.0)).map(|c| (c.0, c.1, c.2))) } /// Whether any of `texts` contains the lowercased query (an empty query matches everything). @@ -5243,6 +5283,104 @@ mod tests { assert!(quick_apply_items(&s, "preferences", false).iter().any(|i| i.id == "app.preferences")); } + #[test] + fn searched_ui_commands_run_without_parameters_and_keep_preferences() { + // Without a value these reset a setting or need a place the test host always has. + let searched: Vec<&str> = quick_commands().map(|c| c.0).collect(); + for id in ["app.flattener", "view.screenMode", "view.zoom", "window.taskBarPin"] { + assert!(!searched.contains(&id), "{id} is searched"); + } + // A placed PDF: a graphic with layers. + let mut scratch = designcraft_engine::Session::new(); + scratch.execute("file.new", &json!({})).unwrap(); + let pdf = scratch.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); + let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected"]; + // A two-page document, a saved workspace and non-default preferences, a host with window + // controls, and file dialogs that are dismissed. + let setup = |context: usize| { + let services = crate::Services { + pick_open: Some(Box::new(|_| None)), + pick_save: Some(Box::new(|_| None)), + clipboard_text: Some(Box::new(|| None)), + ..Default::default() + }; + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.custom_titlebar = true; + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0))); + app.run("file.new", json!({"pages": 2})).unwrap(); + app.session.prefs.recent_files.push("/nowhere/recent.dcraft".into()); + app.ui.shortcuts.insert("view.rulers".into(), "Ctrl+Alt+R".into()); + app.ui.hidden_menu_items.push("view.guides".into()); + app.ui.ui_scale = 1.25; + app.ui.brightness = crate::theme::Brightness::Light; + app.ui.task_bar_at = Some([200.0, 200.0]); + run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); + match context { + 1 => { + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One", "caret": true})).unwrap(); + } + 2 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + } + 3 => { + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + } + 4 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400]})).unwrap()["id"].clone(); + app.run("file.place", json!({"base64": pdf, "name": "a.pdf", "frame": f})).unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + } + _ => {} + } + app.ui.dialog = None; + app + }; + let prefs = |app: &DesignApp| { + let u = &app.ui; + json!({ + "shortcuts": u.shortcuts, "hiddenMenuItems": u.hidden_menu_items, "uiScale": u.ui_scale, + "brightness": u.brightness, "workspace": u.workspace, "customWorkspaces": u.custom_workspaces, + "docking": u.docking, "dockingHidden": u.docking_hidden, "floating": u.floating, + }) + }; + let quick = quick_apply_items(&setup(0).session, "workspace", false); + assert!(quick.iter().any(|i| i.id == "window.newWorkspace")); + // Deleting needs a workspace name: the application bar's workspace menu lists them. + assert!(!quick.iter().any(|i| i.id == "window.deleteWorkspace")); + // Each command runs as Quick Apply runs an entry. One that needs a selection may fail + // without it, but runs in one of the contexts; none changes a preference it wasn't asked to. + // Opening a panel changes where the panels are, as it should. + let opens_a_panel = ["app.tabsPanel", "app.characterStylesPanel", "app.paragraphStylesPanel"]; + let arrangement = ["docking", "dockingHidden", "floating"]; + let mut failed = Vec::new(); + for (id, ..) in quick_commands().filter(|(id, ..)| UI_COMMANDS.iter().any(|c| c.0 == *id)) { + let mut errors = Vec::new(); + for (i, context) in contexts.iter().enumerate() { + let mut app = setup(i); + let before = prefs(&app); + match app.run(id, json!({})) { + Err(e) => errors.push(format!("{context}: {e}")), + Ok(_) => { + let after = prefs(&app); + let changed: Vec<&String> = + before.as_object().unwrap().iter().filter(|(k, v)| after.get(k.as_str()) != Some(*v)).map(|(k, _)| k).collect(); + let changed: Vec<&String> = + changed.into_iter().filter(|k| !(opens_a_panel.contains(&id) && arrangement.contains(&k.as_str()))).collect(); + if !changed.is_empty() { + failed.push(format!("{id} ({context}): changed {changed:?}")); + } + } + } + } + if errors.len() == contexts.len() { + failed.push(format!("{id}: {errors:?}")); + } + } + assert!(failed.is_empty(), "{failed:#?}"); + } + #[test] fn distribute_rows_menu_dispatches_an_undoable_edit() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From befc2e03462bde1588a28356c258267d3753c935 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:37:21 -0400 Subject: [PATCH 13/13] Styles: renaming a style keeps its Export Tagging (#1790) * Styles: renaming a style keeps its Export Tagging Renaming a paragraph or character style, or moving it into a style group, moves its Export Tagging to the new name. Deleting a style drops its tagging and leaves the replacement's own tagging unchanged. * Styles: a paragraph style can't be renamed onto another style's name * Styles: a refused rename says the name is taken and shows the General section Renaming a paragraph or character style onto a name another style has now says so ("a paragraph style named `Body` already exists"); empty or bracketed names keep the generic message. The Paragraph Style Options dialog switches to General, where the name field is, when the rename is refused. Paragraph style names are trimmed like character style names, and a renamed style never picks up an export tag left under its new name. * Styles: the rename test's stale tagging takes the Split Document field * ROADMAP: date the style rename line 2026-10-24 --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 118 ++++++++++++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 36 ++++++++-- 3 files changed, 139 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 541a56e5d..963bc5960 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** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **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. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 9568d842e..5d440a5d4 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -258,7 +258,7 @@ pub fn specs() -> Vec { if !exists { return Err(bad("style.exportTag", format!("no style `{name}`"))); } - let key = format!("{}:{name}", if character { "c" } else { "p" }); + let key = export_tag_key(!character, &name); 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) { @@ -941,7 +941,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; - let rename = str_param(p, "rename").map(str::to_string); + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); let based = p.get("basedOn").cloned(); s.edit(|d, _| { if let Some(Value::String(b)) = &based @@ -949,6 +949,14 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.edit", "based-on would create a cycle")); } + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.paragraph.edit", format!("can't rename to `{n}`"))); + } + if d.styles.para(n).is_some() { + return Err(bad("style.paragraph.edit", format!("a paragraph style named `{n}` already exists"))); + } + } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; @@ -971,24 +979,35 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { }) } -/// Rename a paragraph or character style everywhere it's used. +/// Rename a paragraph or character style everywhere it's used; its Export Tagging follows. fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { + let st = d.styles_mut(); if para { - for s in &mut d.styles_mut().paragraph { + for s in &mut st.paragraph { if s.name == from { s.name = to.to_string(); } } } else { - for s in &mut d.styles_mut().character { + for s in &mut st.character { if s.name == from { s.name = to.to_string(); } } } + // An entry already under `to` belongs to no style (names are unique), so it never survives. + st.export_tags.remove(&export_tag_key(para, to)); + if let Some(tag) = st.export_tags.remove(&export_tag_key(para, from)) { + st.export_tags.insert(export_tag_key(para, to), tag); + } redirect_style_refs(d, para, from, to); } +/// The `Styles::export_tags` key of a paragraph (`para`) or character style. +fn export_tag_key(para: bool, name: &str) -> String { + format!("{}:{name}", if para { "p" } else { "c" }) +} + /// Point every reference to style `from` at `to`, except the style's own registry entry (its /// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which /// instead removes the entry, after its former uses point at the replacement). @@ -1175,8 +1194,10 @@ fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result { for b in based.into_iter().filter(|b| b.as_deref() == Some(name.as_str())) { *b = parent.clone(); } + // The replacement keeps its own Export Tagging. + st.export_tags.remove(&export_tag_key(para, &name)); // Every other use: styles, stories, table cells, nested and GREP styles, notes, defaults. - rename_style(d, para, &name, &repl); + redirect_style_refs(d, para, &name, &repl); ok() }) } @@ -1230,7 +1251,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_object) .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) .unwrap_or_default(); - let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string()); + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); let based: Option> = match p.get("basedOn") { None => None, Some(Value::Null) => Some(None), @@ -1247,10 +1268,13 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { return Err(bad("style.character.edit", "based-on would create a cycle")); } } - if let Some(n) = &rename - && (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some()) - { - return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); + } + if d.styles.char_style(n).is_some() { + return Err(bad("style.character.edit", format!("a character style named `{n}` already exists"))); + } } let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?; st.chars.merge(&chars); @@ -2843,6 +2867,78 @@ mod export_tag_tests { assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements"); assert!(designcraft_render::pdf_page_count(&pdf) == Some(1)); } + + #[test] + fn export_tagging_follows_a_renamed_style_and_goes_with_a_deleted_one() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Term"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Head", "tag": "h2", "class": "head"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Body", "tag": "p", "class": "body"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Term", "character": true, "tag": "em", "class": "term"})).unwrap(); + let tag = |s: &Session, name: &str, character: bool| { + s.doc().unwrap().doc.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone())) + }; + let pair = |t: &str, c: &str| Some((t.to_string(), c.to_string())); + // A rename onto another style's name is refused, so neither tagging is lost. + assert!(s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Body"})).is_err()); + assert_eq!((tag(&s, "Head", false), tag(&s, "Body", false)), (pair("h2", "head"), pair("p", "body"))); + + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Title"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Term", "rename": "Word"})).unwrap(); + assert_eq!(tag(&s, "Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Word", true), pair("em", "term")); + assert_eq!(tag(&s, "Term", true), None); + // Moving a style into a group renames it to Group/Name. + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Title"], "group": "Heads"})).unwrap(); + assert_eq!(tag(&s, "Heads/Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(tag(&s, "Head", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + assert_eq!(tag(&s, "Term", true), pair("em", "term")); + assert_eq!(tag(&s, "Word", true), None); + + // Deleting drops the deleted style's tagging; the replacement keeps its own. + s.execute("style.paragraph.delete", &json!({"name": "Head", "replaceWith": "Body"})).unwrap(); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Body", false), pair("p", "body")); + s.execute("style.character.delete", &json!({"name": "Term"})).unwrap(); + assert_eq!(tag(&s, "Term", true), None); + assert!(s.doc().unwrap().doc.styles.export_tags.keys().all(|k| k == "p:Body")); + } + + #[test] + fn a_rename_onto_a_taken_name_says_so_and_a_trimmed_rename_drops_a_stale_tag() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (kind, a, b) in [("paragraph", "Head", "Body"), ("character", "Term", "Word")] { + s.execute(&format!("style.{kind}.create"), &json!({"name": a})).unwrap(); + s.execute(&format!("style.{kind}.create"), &json!({"name": b})).unwrap(); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": b})).unwrap_err().to_string(); + assert!(e.contains(&format!("a {kind} style named `{b}` already exists")), "{e}"); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": "[None]"})).unwrap_err().to_string(); + assert!(e.contains("can't rename to `[None]`"), "{e}"); + } + // An entry left under a name no style has doesn't attach to a style renamed onto it. + s.edit(|d, _| { + d.styles_mut() + .export_tags + .insert("p:Title".into(), designcraft_doc::ExportTag { tag: "h6".into(), class: "stale".into(), ..Default::default() }); + Ok(()) + }) + .unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": " Title "})).unwrap(); + let st = &s.doc().unwrap().doc.styles; + assert!(st.para("Title").is_some(), "the new name is trimmed"); + assert_eq!(st.export_tag("Title", false), None); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9e44501f0..20a0c58c5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3876,9 +3876,9 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul } let (para, chars) = style_option_maps(d); let based = d.s("basedOn"); - let rename = d.s("rename"); + let rename = d.s("rename").trim().to_string(); let (r, style) = if d.b("new") { - let mut params = json!({"name": if rename.trim().is_empty() { "Paragraph Style 1" } else { rename.trim() }, "para": para, "chars": chars}); + let mut params = json!({"name": if rename.is_empty() { "Paragraph Style 1" } else { rename.as_str() }, "para": para, "chars": chars}); if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE { params["basedOn"] = json!(based); } @@ -3890,11 +3890,17 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul if !based.is_empty() { params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; } - if !rename.is_empty() && rename != name { + let renaming = !rename.is_empty() && rename != name; + if renaming { params["rename"] = json!(rename); } - let style = if !rename.is_empty() && rename != name { rename } else { name }; - (app.run("style.paragraph.edit", params)?, style) + let r = app.run("style.paragraph.edit", params).inspect_err(|_| { + // A refused name is shown next to the name field. + if renaming { + d.fields.insert("section".into(), json!("general")); + } + })?; + (r, if renaming { rename } else { name }) }; // The style now exists under `style`: if a later step fails, the dialog stays open on it and // a second OK edits it rather than creating another one. @@ -7987,6 +7993,26 @@ mod tests { draw(&mut app, &ctx); } + #[test] + fn paragraph_style_options_show_a_refused_rename_on_general_and_trim_a_new_name() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled", "section": "rules"}))); + draw(&mut app, &ctx); + app.ui.dialog.as_mut().unwrap().fields.insert("rename".into(), json!("Ruled Child")); + let e = confirm(&mut app).unwrap_err(); + let d = app.ui.dialog.clone().expect("the dialog stays open"); + assert_eq!(d.s("section"), "general", "the name field is on General"); + assert!(d.s("status").contains("already exists"), "{e}"); + + app.ui.dialog.as_mut().unwrap().fields.insert("rename".into(), json!(" Fresh ")); + app.ui.dialog.as_mut().unwrap().fields.insert("x.tag".into(), json!("h3")); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.styles; + assert!(st.para("Fresh").is_some()); + assert_eq!(st.export_tag("Fresh", false).map(|t| t.tag.as_str()), Some("h3")); + } + /// A frame with two paragraphs of [Basic Paragraph]; the first has a local Word Spacing minimum. fn two_paragraphs() -> (DesignApp, Value) { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());