Merge main into feat/file-info

This commit is contained in:
dotCooCoo
2026-10-24 18:14:03 -07:00
53 changed files with 3095 additions and 112 deletions
+12 -2
View File
@@ -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.
@@ -11,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.
@@ -132,7 +135,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).
@@ -358,6 +361,13 @@ 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.
- **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-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.
+1
View File
@@ -239,6 +239,7 @@ pub fn synthetic(spec: &Spec) -> Result<Document, String> {
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),
+18 -16
View File
@@ -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::<f64>() - 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::<f64>() - measure).max(0.0)) } else { cjk_right };
let hr = optical_right.max(cjk_right);
x0 -= hl;
measure += hl + hr;
+58
View File
@@ -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)),
@@ -1358,6 +1361,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<PlacedGlyph> { 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<f64>, para: ParaAttrs) -> f64 {
+38
View File
@@ -15,6 +15,43 @@ 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(&note.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))
}
/// 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))
|| 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 +393,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)),
+29
View File
@@ -680,10 +680,37 @@ 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<String, String>,
pub layers: Vec<std::collections::BTreeMap<String, String>>,
/// 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<Option<usize>>,
}
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<GraphicLayerOptions>,
pub asset: AssetId,
/// Natural size of the graphic in points (pixels at its resolution, or PDF page size).
pub size: (f64, f64),
@@ -1726,6 +1753,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)),
@@ -1737,6 +1765,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::<Graphic>(v.clone()).unwrap(), g);
// A clip written with only its type and path gets the default options.
+24
View File
@@ -345,6 +345,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 {
@@ -419,6 +439,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 {
@@ -484,6 +507,7 @@ impl Default for DocSettings {
zero_point_locked: false,
column_guides_locked: true,
new_object: NewObjectColors::default(),
epub_split: EpubSplit::Off,
}
}
}
+4
View File
@@ -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 {
+1
View File
@@ -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)),
+79 -3
View File
@@ -13,8 +13,30 @@ pub fn specs() -> Vec<CommandSpec> {
"{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<String>) ->
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<designcraft_doc::EpubSplit> {
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<Value> {
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<Value> {
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)
@@ -434,6 +471,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 +642,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),
@@ -733,9 +772,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
),
};
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);
@@ -743,6 +781,10 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
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<bool> = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect();
@@ -849,6 +891,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();
+3
View File
@@ -723,6 +723,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
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,
@@ -749,6 +750,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
it.effects = e.clone();
}
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (bw, bh),
xf,
@@ -913,6 +915,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
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,
+3
View File
@@ -1168,6 +1168,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,
@@ -1213,6 +1214,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,
@@ -1249,6 +1251,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,
+3 -3
View File
@@ -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::<Vec<_>>(), [("Steps", false)]);
+1
View File
@@ -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),
+2
View File
@@ -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)),
+6 -2
View File
@@ -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);
}
}
+22
View File
@@ -249,6 +249,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
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<Issue> {
}
}
}
// 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 d.has_unwalked_graphic_layer_visibility_overrides() {
out.push(Issue {
severity: "warning",
kind: "unsupportedGraphicLayers",
message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(),
item: None,
page: None,
});
}
out
}
+128 -13
View File
@@ -249,7 +249,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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();
@@ -258,8 +258,12 @@ pub fn specs() -> Vec<CommandSpec> {
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) {
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<CommandSpec> {
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)
@@ -934,7 +941,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
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
@@ -942,6 +949,14 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
{
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, &current))?;
@@ -964,24 +979,35 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// 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).
@@ -1020,6 +1046,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 {
@@ -1165,8 +1194,10 @@ fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result<Value> {
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()
})
}
@@ -1220,7 +1251,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
.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<Option<String>> = match p.get("basedOn") {
None => None,
Some(Value::Null) => Some(None),
@@ -1237,10 +1268,13 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
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);
@@ -2810,6 +2844,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();
@@ -2826,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)]
@@ -2981,6 +3094,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 +3113,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}");
}
+1
View File
@@ -2017,6 +2017,7 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
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,
+1
View File
@@ -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),
@@ -0,0 +1,97 @@
//! 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<u8> {
let xml = r#"<Document><Layer Self="L" Name="Synthetic"/><Spread Self="sp"><Page Self="p" GeometricBounds="0 0 100 100"/>
<Rectangle Self="r" ItemLayer="L" ContentType="GraphicType"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 20"/><PathPointType Anchor="20 20"/><PathPointType Anchor="20 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties><Image Self="im">
<Properties><GraphicBounds Left="0" Top="0" Right="20" Bottom="20"/></Properties>
<Link Self="lk" LinkResourceURI="file:/definitely-missing/synthetic.psd"/>
<GraphicLayerOption UpdateLinkOption="KeepOverrides">
<GraphicLayer Self="g0" Id="0" Name="Group"><GraphicLayer Self="g1" Id="1" Name="Hidden" OriginalVisibility="true" CurrentVisibility="false"/></GraphicLayer>
<GraphicLayer Self="g2" Id="2" Name="Shown" OriginalVisibility="false" CurrentVisibility="true"/>
</GraphicLayerOption></Image></Rectangle></Spread></Document>"#;
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::<Vec<_>>()
};
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::<serde_json::Value>(&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();
// 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())
.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();
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}");
}
}
+5 -7
View File
@@ -118,11 +118,7 @@ fn exported(hidden: bool) -> Vec<u8> {
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}");
}
+238
View File
@@ -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[<</Event/View/Category[/View]/OCGs[5 0 R 6 3 R]>><</Event/Print/Category[/Print]/OCGs[5 0 R 6 3 R]>><</Event/Export/Category[/Export]/OCGs[5 0 R 6 3 R]>>]";
#[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::Dict<'_>>(hayro_syntax::object::ObjectIdentifier::new(id.0, id.1)).is_some(), "{id:?}");
}
}
fn exported(config: &str, standard: Standard) -> Vec<u8> {
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::<hayro_syntax::object::Dict<'_>>(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::<Name<'_>>(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]);
}
@@ -0,0 +1,174 @@
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<u8> {
layered_fixture(Some(config), second_name)
}
pub fn orphaned_fixture() -> Vec<u8> {
layered_fixture(None, "Hidden")
}
fn layered_fixture(config: Option<&str>, second_name: &str) -> Vec<u8> {
let properties = config.map(|config| format!("/OCProperties<</OCGs[5 0 R 6 3 R]/D<<{config}>>>>")).unwrap_or_default();
properties_fixture(&properties, second_name)
}
pub fn properties_fixture(properties: &str, second_name: &str) -> Vec<u8> {
grouped_fixture(properties, Some(second_name))
}
pub fn nameless_fixture(config: &str) -> Vec<u8> {
grouped_fixture(&format!("/OCProperties<</OCGs[5 0 R 6 3 R]/D<<{config}>>>>"), None)
}
fn grouped_fixture(properties: &str, second_name: Option<&str>) -> Vec<u8> {
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!("<</Type/Catalog/Pages 2 0 R{properties}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 3 R>>>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Shown)/Intent[/View/Design]/Usage<</Print<</PrintState/ON>>>>/Custom(keep \\(this\\) >>)>>".into(),
format!("<</Type/OCG{name}/Intent[/View/Design]/Usage<</Print<</PrintState/ON>>>>/Custom<00ff80>>>"),
];
assemble(&objects, &[2, 0, 0, 0, 0, 3])
}
pub fn unlayered_fixture() -> Vec<u8> {
let content = "1 0 0 0 k 0 0 50 100 re f";
assemble(
&[
"<</Type/Catalog/Pages 2 0 R>>".into(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
],
&[2, 0, 0, 0],
)
}
pub fn stream_fixture(config: &str) -> Vec<u8> {
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<</Type/XRef/Size 8/Root 1 2 R/Prev {previous}/W[1 4 2]/Index[7 1]/Length 7>>\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<u8> {
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<</Size {}/Root 1 2 R>>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
pub fn document(bytes: Vec<u8>, 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(),
layer_options: None,
});
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
}
doc
}
pub fn refs(dict: &Dict<'_>, key: &[u8]) -> Vec<ObjectIdentifier> {
dict.get::<Array<'_>>(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::<Dict<'_>>(pdf.xref().root_id()).unwrap();
let properties = root.get::<Dict<'_>>(b"OCProperties").expect("export must register its embedded OCGs");
let groups = refs(&properties, b"OCGs");
let defaults = properties.get::<Dict<'_>>(b"D").unwrap();
assert!(defaults.get::<hayro_syntax::object::String<'_>>(b"Name").is_some());
assert_eq!(defaults.get::<Name<'_>>(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::<Dict<'_>>(id).unwrap();
assert!(group.contains_key(b"Usage"));
assert!(group.contains_key(b"Intent"));
assert!(group.contains_key(b"Custom"));
let custom = group.get::<hayro_syntax::object::String<'_>>(b"Custom").unwrap();
assert!([b"keep (this) >>".as_slice(), &[0, 255, 128]].contains(&custom.as_bytes()));
let intent = group.get::<Array<'_>>(b"Intent").unwrap();
assert_eq!(intent.iter::<Name<'_>>().map(|name| name.as_ref().to_vec()).collect::<Vec<_>>(), [b"View".to_vec(), b"Design".to_vec()]);
let usage = group.get::<Dict<'_>>(b"Usage").unwrap().get::<Dict<'_>>(b"Print").unwrap();
assert_eq!(usage.get::<Name<'_>>(b"PrintState").unwrap().as_ref(), b"ON");
let name = group.get::<hayro_syntax::object::String<'_>>(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:?}");
}
}
@@ -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<</BaseState/Unchanged/OFF[6 3 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/Other/OFF[6 3 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState 42/OFF[6 3 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON 42>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON[null 5 0 R]>>",
];
fn incomplete_inventory(standard: &str, off: bool) {
let cases: &[&str] = if off {
&["/OCGs[5 0 R]/D<</BaseState/ON/OFF[6 3 R]>>", "/D<</ON[5 0 R]/OFF[6 3 R]>>"]
} else {
&["/OCGs[6 3 R]/D<</BaseState/OFF/ON[5 0 R]>>"]
};
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<</OCGs[5 0 R]/D<</BaseState/Unchanged/OFF[6 3 R]>>>>", "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<u8>, 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<</ON[6 3 R]/OFF[6 3 R]>>",
"/OCGs[5 0 R]/D<</BaseState/Unchanged/ON[6 3 R]/OFF[6 3 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/Unchanged/OFF[99 0 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</ON[99 0 R]>>",
"/OCGs[5 0 R 6 3 R 99 0 R]",
"/OCGs[5 0 R 6 3 R]/D<</OFF[2 0 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</OFF[6 0 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</OFF[null]>>",
"/OCGs[5 0 R 6 3 R]/D<</OFF 42>>",
"/OCGs[5 0 R 6 3 R]/D<</OFF 99 0 R>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON[null 99 0 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON 99 0 R>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON 42/OFF[6 3 R]>>",
] {
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}");
}
}
}
}
@@ -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<u8>) -> Vec<u8> {
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::<hayro_syntax::object::Dict<'_>>(pdf.xref().root_id()).unwrap();
assert!(root.get::<hayro_syntax::object::Dict<'_>>(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<u8>, second: Vec<u8>) -> 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]);
}
+182 -26
View File
@@ -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<i64>,
/// Title of the navigation (table of contents); from `language` when absent.
pub toc_title: Option<String>,
/// Split Document: where content files start; the document's setting when absent.
pub split: Option<EpubSplit>,
}
/// 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<String>,
/// 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("</pre>\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 = "<!--dc:chapter-->";
/// 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<String> {
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.metadata_title().to_string());
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!(
"<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"generator\" content=\"DesignCraft\">\n<title>{}</title>\n<style>\nbody {{ max-width: 40em; margin: 2em auto; padding: 0 1em; }}\nfigure {{ margin: 1em 0; }} figure img {{ max-width: 100%; height: auto; }}\n{}</style>\n</head>\n<body>\n{body}</body>\n</html>\n",
xml_text(&title),
@@ -1398,9 +1419,9 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr
let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string());
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<String> = split_chapters(&body)
.into_iter()
.map(|part| {
@@ -1532,6 +1553,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,
@@ -1653,7 +1675,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 +1720,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 +1741,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!("<a href=\"{f}#{t}\">{l}</a>");
// Each h1 starts a chapter file (#768).
// Split Document is off: every heading is in the one content file.
let want = format!(
"<h1>Inhoud</h1><ol><li>{}<ol><li>{}</li></ol></li><li>{}</li></ol>",
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 +1786,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 +1801,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 +1813,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 +1847,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("<h1 id=\"s1-p1\"") && !c2.contains("Part Two"), "the h1 stays with its part: {c2}");
assert!(c3.contains("Part Two") && c3.contains("<h1 id=\"s1-p4\"") && c3.contains("End note"), "{c3}");
for c in [&c1, &c2, &c3] {
assert_eq!(c.matches("<section").count(), c.matches("</section>").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 +1915,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 +1927,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 +1940,61 @@ mod tests {
assert!(last.contains("Second text") && last.contains("<a href=\"content.xhtml#enref-1\">"), "{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<String> =
["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 [("<ul", "</ul>"), ("<pre", "</pre>"), ("<section", "</section>")] {
assert_eq!(c.matches(open).count(), c.matches(close).count(), "{open}: {c}");
}
}
assert!(files[1].contains("<ul") && files[2].contains("<ul") && files[3].contains("<pre") && files[4].contains("<pre"));
assert!(files[4].contains("End"));
assert!(z.by_name("OEBPS/content6.xhtml").is_err());
}
#[test]
fn split_chapters_keeps_sections_well_formed() {
let m = CHAPTER_MARK;
@@ -1940,6 +2091,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,
@@ -2034,9 +2186,10 @@ mod tests {
d.title = "Field Notes & <Edition>".into();
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B <C> \"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\" <Alt>".into());
}
@@ -2056,6 +2209,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,
@@ -2194,7 +2348,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();
+2 -2
View File
@@ -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) {
@@ -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,
+39 -6
View File
@@ -22,8 +22,11 @@ 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. 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)]
pub enum FormatError {
@@ -67,6 +70,19 @@ pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result<Vec<u
/// 4 GiB written without them; the margin covers deflate output that is larger than its input.
const LARGE_ENTRY: usize = (u32::MAX as usize).saturating_sub(64 << 20);
/// The oldest reader capability that keeps everything in `doc`. Documents that need nothing newer
/// keep their older requirement so existing readers still open them.
fn required_version(doc: &Document) -> 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<Vec<u8>, 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,10 +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.
let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } 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 {
@@ -404,6 +417,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)),
@@ -473,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() });
+68
View File
@@ -20,6 +20,15 @@ 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 != "xmlns"
&& 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 {
@@ -778,6 +787,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 +866,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());
@@ -2071,6 +2092,53 @@ 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);
}
}
// 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();
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<El>> = 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);
}
let uri = match &asset.link {
Some(l) => path_to_uri(l),
None => format!("file:{}", asset.name),
+47
View File
@@ -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();
@@ -3056,6 +3082,26 @@ 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();
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));
}
let gxf = parse_affine(g.get("ItemTransform"));
let (l, t, r, b) = match g.prop_el("GraphicBounds") {
Some(gb) => {
@@ -3072,6 +3118,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)),
+55
View File
@@ -4507,6 +4507,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,
@@ -4631,3 +4632,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#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12" SplitDocument="false"/>
<ParagraphStyle Self="ParagraphStyle/Part" Name="Part" SplitDocument="true"/>
<ParagraphStyle Self="ParagraphStyle/Chapter" Name="Chapter" SplitDocument="false">
<StyleExportTagMap Self="uaStyleExportTagMapnEPUB" ExportType="EPUB" ExportTag="h1" ExportClass="" ExportAttributes="" SplitDocument="true" EmitCss="true"/>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" SplitDocument="false"/>
</RootParagraphStyleGroup>
</idPkg:Styles>"#;
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("</RootParagraphStyleGroup>", r#"<ParagraphStyle Self="ParagraphStyle/Chapter" Name="Chapter"/></RootParagraphStyleGroup>"#);
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("</idPkg:Preferences>", &format!("<EPubExportPreference {attrs}/></idPkg:Preferences>"));
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}");
}
}
+123
View File
@@ -70,6 +70,129 @@ fn package(frames: &[String]) -> Vec<u8> {
])
}
#[test]
fn nested_graphic_layers_preserve_hierarchy_on_roundtrip() {
let option = r#"<GraphicLayerOption UpdateLinkOption="KeepOverrides">
<GraphicLayer Self="a" Id="1" Name="Group"><GraphicLayer Self="b" Id="2" Name="Nested" OriginalVisibility="true" CurrentVisibility="false"/></GraphicLayer>
<GraphicLayer Self="c" Id="3" Name="Sibling"/>
</GraphicLayerOption>"#;
let image = image(1, PHOTO, None).replace("</Image>", &format!("{option}</Image>"));
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| {
format!(
r#"<GraphicLayerOption UpdateLinkOption="KeepOverrides"><GraphicLayer Self="gl{n}" Id="7" Name="Synthetic layer" OriginalVisibility="true" CurrentVisibility="{visible}" Locked="true" HasPrintState="true" PrintState="false"/></GraphicLayerOption>"#
)
};
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("</Image>", &format!("{}</Image>", option(n, visible))))
})
.collect::<Vec<_>>();
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("<GraphicLayer ").count(), 2, "both placements must retain their independent layer choices");
assert!(spreads.contains("CurrentVisibility=\"false\""));
assert!(spreads.contains("CurrentVisibility=\"true\""));
assert_eq!(spreads.matches("UpdateLinkOption=\"KeepOverrides\"").count(), 2);
assert_eq!(spreads.matches("PrintState=\"false\"").count(), 2);
assert!(loaded.warnings.iter().any(|w| w.contains("layer visibility")));
let reopened = import_idml(&out).unwrap();
let options = |d: &Document| {
d.spreads[0]
.items
.iter()
.filter_map(|i| match &i.content {
Content::Graphic(g) => g.layer_options.clone(),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(options(&loaded.document), options(&reopened));
let twice = zip_text(&export_idml(&reopened), "Spreads/");
assert_eq!(twice.matches("<GraphicLayer ").count(), 2);
}
#[test]
fn malformed_native_graphic_layer_parents_cannot_cycle_or_overflow_xml_depth() {
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")
};
let mut options = designcraft_doc::GraphicLayerOptions::default();
for i in 0..300usize {
options.layers.push(std::collections::BTreeMap::from([("Name".into(), format!("Layer {i}"))]));
options.parents.push(Some(i.saturating_sub(1)));
}
options.parents[1] = Some(usize::MAX);
g.layer_options = Some(options);
let reopened = import_idml(&export_idml(&d)).unwrap();
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::<Vec<_>>(), ["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();
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")
};
let attributes = std::collections::BTreeMap::from([
("Name".into(), "A < B & C".into()),
("Self".into(), "injected-id".into()),
("xmlns".into(), "urn:injected".into()),
("bad\" injected=\"true".into(), "invalid".into()),
]);
g.layer_options = Some(designcraft_doc::GraphicLayerOptions { attributes: attributes.clone(), layers: vec![attributes], ..Default::default() });
let out = export_idml(&d);
let xml = zip_text(&out, "Spreads/");
assert!(!xml.contains("injected-id") && !xml.contains("injected=") && !xml.contains("urn:injected"));
let back = import_idml(&out).unwrap();
let Content::Graphic(g) = &back.spreads[0].items[0].content else { panic!("graphic") };
assert_eq!(g.layer_options.as_ref().unwrap().layers[0]["Name"], "A < B & C");
}
/// (asset, crop, transform) of each graphic, left to right.
fn placements(d: &Document) -> Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> {
let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0]
+136 -16
View File
@@ -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;
@@ -182,6 +186,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(),
@@ -240,6 +249,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;
@@ -248,6 +260,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)?;
}
add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts);
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
@@ -274,8 +289,11 @@ fn merge_pdfs_inner(parts: &[(Vec<u8>, 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()), 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));
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)?;
@@ -292,7 +310,8 @@ fn merge_pdfs_inner(parts: &[(Vec<u8>, 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.
@@ -352,6 +371,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(),
@@ -401,10 +425,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 })
@@ -499,7 +529,12 @@ pub(crate) struct Exporter<'a> {
#[cfg(test)]
image_conversions: usize,
/// Placed PDFs (embedded as vector pages) and their page counts.
pdfs: HashMap<AssetId, Option<(krilla::pdf::PdfDocument, usize)>>,
pdfs: HashMap<(AssetId, Vec<hayro_syntax::object::ObjectIdentifier>), Option<(krilla::pdf::PdfDocument, usize)>>,
variants: HashMap<(AssetId, Option<Vec<String>>), crate::optional_content::Variant>,
has_optional_content: bool,
#[cfg(test)]
pdf_preparations: usize,
layer_error: Option<PdfError>,
/// 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.
@@ -982,7 +1017,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.is_empty()).then_some(it.pdf_hidden_layers.as_slice()));
s.pop();
}
}
@@ -1275,9 +1310,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<Vec<u8>>) -> 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;
};
@@ -1295,7 +1330,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
{
@@ -1388,25 +1423,30 @@ 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) {
/// 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.
/// Retained IDML graphic-layer options are not applied when drawing, so they warn here.
pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) {
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(&region), 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: 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()
@@ -1440,11 +1480,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.map(<[String]>::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
}
};
@@ -1454,6 +1522,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 {
@@ -1901,6 +1970,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(),
@@ -1915,6 +1988,52 @@ 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();
// 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(&[
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[5 0 R]>>>>".into(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R>>>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Hidden)>>".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);
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, 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),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
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());
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, None)).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());
@@ -2184,6 +2303,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),
+1
View File
@@ -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};
+459
View File
@@ -0,0 +1,459 @@
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())
}
/// 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<Vec<ObjectIdentifier>> {
if !dict.contains_key(key) {
return Ok(Vec::new());
}
dict.get::<Array<'_>>(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<Vec<u8>> {
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::<Name<'_>>().ok_or_else(invalid)?;
reader.skip_white_spaces_and_comments();
reader.skip::<MaybeRef<Object<'_>>>(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)
}
/// 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<Dict<'_>> {
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::<ObjectIdentifier>()?;
reader.skip_white_spaces_and_comments();
let stream = reader.read_without_context::<Stream<'_>>()?;
let is_xref = stream.dict().get::<Name<'_>>(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::<u32>()?;
reader.skip_white_spaces();
let count = reader.read_without_context::<usize>()?;
if count > MAX_SECTION_BYTES / 18 {
return None;
}
for _ in 0..count {
reader.skip_white_spaces();
reader.read_without_context::<u32>()?;
reader.skip_white_spaces();
if reader.read_without_context::<u32>()? > 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::<Dict<'_>>()
}
/// 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::<Object<'_>>(key), Some(MaybeRef::NotRef(Object::Null(_))))
}
fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec<u8>)], root: ObjectIdentifier) -> Result<Vec<u8>> {
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 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());
}
let section = trailer_at(bytes, at).ok_or_else(damaged)?;
if has_entry(&section, b"Encrypt") {
return Err(invalid());
}
offset = section.get::<usize>(b"Prev");
if offset.is_none() && has_entry(&section, b"Prev") {
return Err(damaged());
}
}
if offset.is_some() {
return Err(damaged());
}
trailer.get::<i32>(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<Vec<u8>>,
pub hidden: Vec<ObjectIdentifier>,
pub warning: Option<String>,
pub has_optional_content: bool,
}
/// 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<Vec<u8>>, choice: Option<&[String]>) -> Result<Variant> {
let hidden = choice.unwrap_or_default();
let pdf = Pdf::new(bytes.clone()).map_err(|_| invalid())?;
let xref = pdf.xref();
let root = xref.get::<Dict<'_>>(xref.root_id()).ok_or_else(invalid)?;
// A null value, literal or referenced, is the same as no entry.
if matches!(root.get::<Object<'_>>(b"OCProperties"), None | Some(Object::Null(_))) {
let has_optional_content = pdf
.objects()
.into_iter()
.any(|object| matches!(object, Object::Dict(dict) if dict.get::<Name<'_>>(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::<Dict<'_>>(b"OCProperties").ok_or_else(invalid)?;
let mut groups = references(&properties, b"OCGs")?;
let config = properties.get::<Dict<'_>>(b"D");
let mut fallback = config.is_none();
let config = config.unwrap_or_default();
let base_off = match config.get::<Name<'_>>(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::<Object<'_>>(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;
let mut found = vec![false; hidden.len()];
for id in groups {
let group = xref.get::<Dict<'_>>(id).ok_or_else(invalid)?;
if group.get::<Name<'_>>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") {
return Err(invalid());
}
let name = group.get::<hayro_syntax::object::String<'_>>(b"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) {
if n == name.as_ref() {
(*found, is_hidden) = (true, true);
}
}
is_hidden
}
(Some(_), 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::<Object<'_>>(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));
}
if found.contains(&false) {
return Err(missing_layer());
}
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<Vec<u8>> {
#[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::<Name<'_>>(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::<bool>(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::<Vec<_>>()
.join(" ")
};
let all = refs(false);
let properties = format!("<</OCGs[{all}]/D<</Name(Placed PDF layers)/BaseState/ON/OFF[{}]/Order[{all}]>>>>", refs(true));
let root = pdf.xref().root_id();
let catalog = pdf.xref().get::<Dict<'_>>(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<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[4 0 R]/D<</BaseState/ON>>>>>>\nendobj\n\
2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n\
3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>>>\nendobj\n\
4 0 obj\n<</Type/OCG/Name(Visible)>>\nendobj\n\
trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n1\n%%EOF\n"
.to_vec(),
);
assert!(Pdf::new(bytes.clone()).is_ok());
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<Vec<u8>> {
let mut bytes = b"%PDF-1.5\n".to_vec();
let mut offsets = Vec::new();
for body in [
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[4 0 R]/D<</OFF[4 0 R]>>>>>>",
"<</Type/Pages/Kids[3 0 R]/Count 1>>",
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>>>",
"<</Type/OCG/Name(Off)>>",
] {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes());
}
let table = |bytes: &mut Vec<u8>, 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("<</Size 5/Root 1 0 R>>", 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::<Dict<'_>>(pdf.xref().root_id()).unwrap();
let config = catalog.get::<Dict<'_>>(b"OCProperties").unwrap().get::<Dict<'_>>(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<Vec<u8>> {
append(bytes, &[(ObjectIdentifier::new(4, 0), b"<</Type/OCG/Name(Off)>>".to_vec())], ObjectIdentifier::new(1, 0))
}
#[test]
fn a_null_encrypt_entry_is_no_encryption() {
for trailer in ["<</Size 5/Root 1 0 R/Encrypt null>>", "<</Size 5/Root 1 0 R>>"] {
assert!(update(&default_hidden(trailer, None)).is_ok(), "{trailer}");
}
for trailer in ["<</Size 5/Root 1 0 R/Encrypt 9 0 R>>", "<</Size 5/Root 1 0 R/Encrypt<</Filter/Standard>>>>"] {
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("<</Size 5/Root 1 0 R/Encrypt null>>", None), None).is_ok());
}
#[test]
fn encryption_in_an_earlier_revision_is_found() {
let bytes = default_hidden("<</Size 5/Root 1 0 R>>", Some("<</Size 5/Root 1 0 R/Encrypt 9 0 R>>"));
assert!(update(&bytes).is_err());
let bytes = default_hidden("<</Size 5/Root 1 0 R>>", Some("<</Size 5/Root 1 0 R/Encrypt null>>"));
assert!(update(&bytes).is_ok());
}
#[test]
fn a_trailer_in_a_comment_does_not_hide_the_real_one() {
let bytes = b"xref\n% trailer <</Size 9/Encrypt null>>\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9/Encrypt 12 0 R>>";
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<</Size 9/Encrypt 12 0 R>>";
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!("<</Size 5/Root 1 0 R/Prev {value}>>"), None);
let error = update(&bytes).unwrap_err().to_string();
assert!(error.contains("cross-reference"), "{value}: {error}");
}
assert!(update(&default_hidden("<</Size 5/Root 1 0 R/Prev null>>", None)).is_ok());
}
#[test]
fn a_cyclic_prev_chain_is_damage() {
let mut bytes = default_hidden("<</Size 5/Root 1 0 R>>", None).to_vec();
let at = crate::transitions::rfind(&bytes, b"xref\n").unwrap();
let patched = String::from_utf8(bytes.clone()).unwrap().replacen("<</Size 5/Root 1 0 R>>", &format!("<</Size 5/Root 1 0 R/Prev {at}>>"), 1);
bytes = patched.into_bytes();
let error = update(&bytes).unwrap_err().to_string();
assert!(error.contains("cross-reference"), "{error}");
}
}
+1
View File
@@ -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),
+224
View File
@@ -0,0 +1,224 @@
use super::*;
fn placed_document(source: Vec<u8>) -> 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(),
layer_options: None,
});
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
doc
}
fn layered_source(catalog: bool) -> Vec<u8> {
let properties = if catalog { "/OCProperties<</OCGs[5 0 R]/D<</OFF[5 0 R]>>>>" } else { "" };
let content = b"/OC /a BDC 0.125 0.625 0.875 rg 0 0 100 100 re f EMC";
let objects = [
format!("<</Type/Catalog/Pages 2 0 R{properties}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R>>>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{}\nendstream", content.len(), String::from_utf8_lossy(content)),
"<</Type/OCG/Name(Hidden)>>".into(),
];
assemble(&objects)
}
fn unlayered_source() -> Vec<u8> {
let content = "0.125 0.625 0.875 rg 0 0 100 100 re f";
assemble(&[
"<</Type/Catalog/Pages 2 0 R>>".into(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
])
}
pub(super) fn assemble(objects: &[String]) -> Vec<u8> {
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<</Size {size}/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
bytes
}
thread_local! {
pub(crate) static REGISTRATIONS: std::cell::Cell<usize> = 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::<Name<'_>>(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::<Dict<'_>>(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 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!("<</Type/Catalog/Pages 2 0 R{properties}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\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] {
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::<Dict<'_>>(xref.root_id()).unwrap();
let properties = catalog.get::<Dict<'_>>(b"OCProperties").unwrap();
let groups = properties.get::<Array<'_>>(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::<Dict<'_>>(group_id).unwrap();
assert_eq!(group.get::<Name<'_>>(b"Type").unwrap().as_ref(), b"OCG");
assert_eq!(group.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap().as_bytes(), b"Hidden");
let config = properties.get::<Dict<'_>>(b"D").unwrap();
if hidden {
let off = config.get::<Array<'_>>(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::<Array<'_>>(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::<Name<'_>>(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());
}
}
}
+1
View File
@@ -257,6 +257,7 @@ fn place(d: &mut Document, data: Vec<u8>, 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),
+1 -1
View File
@@ -309,7 +309,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();
}
@@ -30,6 +30,7 @@ fn place_named(d: &mut Document, data: Vec<u8>, 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)),
+1
View File
@@ -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)),
+2
View File
@@ -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)),
+1
View File
@@ -51,6 +51,7 @@ fn shown(file: Vec<u8>, 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)),
+1
View File
@@ -78,6 +78,7 @@ fn placed(size: f64, pdf: Vec<u8>) -> 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,
+4
View File
@@ -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)),
+167 -6
View File
@@ -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<String> = 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<String> = d
.fields
@@ -2233,6 +2291,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"),
@@ -2972,6 +3031,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"fileInfo" => file_info_dialog(app, ui, &mut d),
"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() {
@@ -3505,6 +3565,15 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
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");
@@ -3906,9 +3975,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);
}
@@ -3920,11 +3989,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.
@@ -3934,7 +4009,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)
}
@@ -4085,6 +4160,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:"));
@@ -4104,6 +4180,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),
@@ -8096,6 +8173,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());
@@ -10067,6 +10164,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<Mutex<Vec<u8>>> = 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!({}));
+164 -6
View File
@@ -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)…",
@@ -1118,10 +1123,11 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
}
"app.exportPng" => 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!({}) };
@@ -2111,6 +2117,16 @@ fn export_idml(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
/// 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<Value, String> {
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<Value, String> {
let st = app.session.active().ok_or("no document")?;
let path = match p.get("path").and_then(Value::as_str) {
@@ -2121,7 +2137,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" });
@@ -3354,13 +3374,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<Item = (&'static str, &'static str, Option<&'static str>)> {
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).
@@ -5228,6 +5288,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());
+1
View File
@@ -3208,6 +3208,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,
+3 -1
View File
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; 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 16 shrunk: File › File Info; gap 16: Convert Variable to Text; 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.
@@ -205,6 +205,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.
+52
View File
@@ -0,0 +1,52 @@
# 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 and parent-child relationships, 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, 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, parents in closed subtrees and over-budget nesting
are emitted as top-level layers, retaining their scalar metadata without
unbounded XML recursion.
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, including
when spread items also produce warnings. PDF warnings inspect the source
document so effect rasterization and transparency flattening cannot hide them.
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.
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,
and invalid native attribute names and parent indices. 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.
+4 -2
View File
@@ -394,12 +394,14 @@ 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).
- 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.