From f845c583702d5db477170fe65dc1091be924d7af Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Thu, 15 Oct 2026 23:57:40 -0400
Subject: [PATCH 01/43] ci: switch to shared nix workflow from
craft-common-actions
---
.github/workflows/nix.yml | 16 +++-------------
1 file changed, 3 insertions(+), 13 deletions(-)
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 18392587a..01cb9cd66 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
- eval:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- - name: nix flake check --no-build
- run: |
- if [ -f flake.nix ]; then
- nix flake check --no-build --all-systems
- else
- echo "No flake.nix present in this repository; skipping check."
- fi
+ nix:
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
From 5c1aea75330540a99ffa37c4c97a26461420770d Mon Sep 17 00:00:00 2001
From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com>
Date: Sat, 10 Oct 2026 09:17:30 -0600
Subject: [PATCH 02/43] Place: an explicit x, y, spread or width puts the image
in a new frame
Explicit image placement leaves selected frames untouched; an explicit frame still wins. Null placement parameters behave like omitted values.
Reject nonnumeric, nonfinite and nonpositive image widths before editing. Cover rejection without document changes, valid sizing and null selection behavior with regression tests.
---
crates/engine/src/cmd/file.rs | 291 +++++++++++++++++++++++++++++++++-
1 file changed, 288 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index f63f0d6e9..f18f1d7b9 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -103,7 +103,7 @@ pub fn specs() -> Vec {
"Place…",
["File"],
Some("Cmd+D"),
- "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}",
+ "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}",
has_doc,
file_place
),
@@ -327,6 +327,16 @@ fn file_place(s: &mut Session, p: &Value) -> Result {
if designcraft_textimport::is_text_file(&name) {
return super::place_text::place_text(s, p, &name, &bytes, link, before);
}
+ let want_w = p
+ .get("width")
+ .filter(|value| !value.is_null())
+ .map(|value| {
+ value
+ .as_f64()
+ .filter(|width| width.is_finite() && *width > 0.0)
+ .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero"))
+ })
+ .transpose()?;
// Image Import Options: which page of a PDF (1-based).
let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) {
Some(n) if designcraft_render::is_pdf(&bytes) => {
@@ -362,14 +372,20 @@ fn file_place(s: &mut Session, p: &Value) -> Result {
None => None,
};
let target_frame = super::id_param(p, "frame").or_else(|| {
+ if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) {
+ return None;
+ }
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
- let spread = super::spread_param(p, "file.place")?;
+ // `"spread": null` counts as omitted, like the other placement parameters.
+ let spread = match p.get("spread") {
+ Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0),
+ _ => super::spread_param(p, "file.place")?,
+ };
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
- let want_w = p.get("width").and_then(Value::as_f64);
if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) {
let mime = designcraft_render::image_mime(&bytes).to_string();
let asset = Asset { page: pdf_page, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) };
@@ -734,6 +750,275 @@ fn file_revert(s: &mut Session, _: &Value) -> Result {
}
}
+#[cfg(all(test, not(target_arch = "wasm32")))]
+mod image_place_tests {
+ use designcraft_doc::{Content, ItemId};
+ use designcraft_geom::Rect;
+ use serde_json::{Value, json};
+
+ use crate::Session;
+
+ fn image_file() -> std::io::Result {
+ image_file_with_color(30)
+ }
+
+ fn image_file_with_color(red: u8) -> std::io::Result {
+ let file = tempfile::Builder::new().suffix(".png").tempfile()?;
+ let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png();
+ std::fs::write(file.path(), png)?;
+ Ok(file)
+ }
+
+ #[test]
+ fn consecutive_path_placements_create_distinct_graphics() {
+ let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()];
+ let mut session = Session::new();
+ session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
+ let mut placements = Vec::new();
+ let mut originals = Vec::new();
+
+ for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) {
+ let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap();
+ let id = ItemId(placed["id"].as_u64().unwrap());
+ assert!(
+ placements.iter().all(|previous: &Value| previous["id"] != placed["id"]),
+ "each explicit placement needs a distinct frame: {placed}"
+ );
+ let document = &session.doc().unwrap().doc;
+ let item = document.item(id).unwrap();
+ assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0));
+ assert!(document.spreads[spread].items.iter().any(|item| item.id == id));
+ originals.push(serde_json::to_value(item).unwrap());
+ placements.push(placed);
+ }
+
+ let document = &session.doc().unwrap().doc;
+ assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3);
+ assert_eq!(document.assets.len(), 3);
+ for (placed, original) in placements.iter().zip(originals) {
+ assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original);
+ }
+ let links = session.execute("links.list", &json!({})).unwrap();
+ assert_eq!(links.as_array().unwrap().len(), 3);
+ for (placed, image) in placements.into_iter().zip(&images) {
+ let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap();
+ assert_eq!(row["path"], json!(image.path()));
+ assert_eq!(row["uses"].as_array().unwrap().len(), 1);
+ assert_eq!(row["uses"][0]["id"], placed["id"]);
+ }
+ }
+
+ #[test]
+ fn individual_placement_parameters_bypass_selected_frames() {
+ let image = image_file().unwrap();
+ for graphic in [false, true] {
+ for (key, value, expected) in [
+ ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)),
+ ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)),
+ ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)),
+ ("width", 0, Rect::ZERO),
+ ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)),
+ ] {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({})).unwrap();
+ let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap();
+ if graphic {
+ session.execute("file.place", &json!({"path": image.path()})).unwrap();
+ }
+ let id = ItemId(selected["id"].as_u64().unwrap());
+ let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap();
+ let mut params = json!({"path": image.path()});
+ params[key] = json!(value);
+
+ let result = session.execute("file.place", ¶ms);
+ if key == "width" && value == 0 {
+ assert!(result.is_err(), "zero width must be rejected, graphic={graphic}");
+ continue;
+ }
+ let placed = result.unwrap();
+
+ assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}");
+ let document = &session.doc().unwrap().doc;
+ assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original);
+ let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap();
+ assert_eq!(item.bounds(), expected);
+ let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") };
+ let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs();
+ assert!(scale_x > 0.0 && scale_y > 0.0);
+ }
+ }
+ }
+
+ #[test]
+ fn explicit_frame_wins_over_placement_and_selection() {
+ let image = image_file().unwrap();
+ let mut session = Session::new();
+ session.execute("file.new", &json!({})).unwrap();
+ let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap();
+ let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap();
+
+ let placed =
+ session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap();
+
+ assert_eq!(placed["id"], target["id"]);
+ let document = &session.doc().unwrap().doc;
+ let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap();
+ assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0));
+ assert!(matches!(item.content, Content::Graphic(_)));
+ assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned));
+ assert_eq!(document.spreads[0].items.len(), 2);
+ }
+
+ fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> {
+ let image = image_file()?;
+ for graphic in [false, true] {
+ for explicit_frame in [false, true] {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({}))?;
+ let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?;
+ if graphic {
+ session.execute("file.place", &json!({"path": image.path()}))?;
+ }
+ let mut params = json!({"path": image.path(), "width": width});
+ if explicit_frame {
+ params["frame"] = target["id"].clone();
+ session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?;
+ }
+ let before = session.doc()?;
+ let document = before.doc.clone();
+ let selection = before.selection.clone();
+ let revision = before.revision;
+ let assets = document.assets.len();
+
+ let result = session.execute("file.place", ¶ms);
+
+ assert!(
+ matches!(&result, Err(crate::EngineError::BadParams { .. })),
+ "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}"
+ );
+ let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?;
+ let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) };
+ assert_eq!(cmd, "file.place");
+ assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}");
+ let after = session.doc()?;
+ assert!(std::sync::Arc::ptr_eq(&document, &after.doc));
+ assert_eq!(after.selection, selection);
+ assert_eq!(after.revision, revision);
+ assert_eq!(after.doc.assets.len(), assets);
+ }
+ }
+ Ok(())
+ }
+
+ #[test]
+ fn width_rejected_when_zero() {
+ assert_width_rejected(json!(0)).unwrap();
+ }
+
+ #[test]
+ fn width_rejected_when_negative() {
+ assert_width_rejected(json!(-80)).unwrap();
+ }
+
+ #[test]
+ fn width_rejected_when_non_numeric() {
+ for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] {
+ assert_width_rejected(width).unwrap();
+ }
+ }
+
+ #[test]
+ fn numeric_overflow_rejected_when_parsing_json() {
+ let result = serde_json::from_str::(r#"{"width":1e400}"#);
+ let error = result.unwrap_err();
+ assert!(error.is_data() || error.is_syntax(), "{error}");
+ eprintln!("overflow parser rejected width=1e400: {error}");
+ }
+
+ fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> {
+ let image = image_file()?;
+ let previous_image = image_file_with_color(31)?;
+ for graphic in [false, true] {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({}))?;
+ let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?;
+ let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?;
+ let mut params = json!({"path": image.path()});
+ for key in keys {
+ params[*key] = Value::Null;
+ }
+
+ let placed = session.execute("file.place", ¶ms)?;
+
+ assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}");
+ if let Some(previous) = previous {
+ assert_ne!(placed["asset"], previous["asset"]);
+ }
+ let document = &session.doc()?.doc;
+ let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?);
+ let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
+ assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0));
+ assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic");
+ let Content::Graphic(content) = &item.content else {
+ return Err(crate::EngineError::Other("placed frame must be graphic".into()).into());
+ };
+ assert_eq!(json!(content.asset.0), placed["asset"]);
+ assert_eq!(document.spreads[0].items.len(), 1);
+ }
+ Ok(())
+ }
+
+ #[test]
+ fn selection_used_when_x_null() {
+ assert_null_placement_uses_selection(&["x"]).unwrap();
+ }
+
+ #[test]
+ fn selection_used_when_y_null() {
+ assert_null_placement_uses_selection(&["y"]).unwrap();
+ }
+
+ #[test]
+ fn selection_used_when_spread_null() {
+ assert_null_placement_uses_selection(&["spread"]).unwrap();
+ }
+
+ #[test]
+ fn selection_used_when_width_null() {
+ assert_null_placement_uses_selection(&["width"]).unwrap();
+ }
+
+ #[test]
+ fn selection_used_when_all_placement_parameters_null() {
+ assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap();
+ }
+
+ #[test]
+ fn implicit_placement_fills_empty_and_replaces_graphic_selection() {
+ let image = image_file().unwrap();
+ let previous_image = image_file_with_color(31).unwrap();
+ for graphic in [false, true] {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({})).unwrap();
+ let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap();
+ let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap());
+
+ let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap();
+
+ assert_eq!(placed["id"], selected["id"]);
+ if let Some(previous) = previous {
+ assert_ne!(placed["asset"], previous["asset"]);
+ }
+ let document = &session.doc().unwrap().doc;
+ let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap();
+ assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0));
+ let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") };
+ assert_eq!(json!(content.asset.0), placed["asset"]);
+ assert_eq!(document.spreads[0].items.len(), 1);
+ }
+ }
+}
+
#[cfg(test)]
mod pdf_crop_tests {
use serde_json::json;
From 0613948d3d1da1d968a3333747eb890a62a21dc1 Mon Sep 17 00:00:00 2001
From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com>
Date: Sat, 10 Oct 2026 09:53:25 -0600
Subject: [PATCH 03/43] SVG: a font family named in any case finds the
installed font
DesignCraft matched an SVG font-family case-insensitively when importing
the installed faces, but the final fontdb query compares names exactly,
so "DeJaVu SaNs" loaded DejaVu Sans and then set the text in the serif
fallback (Source Serif 4) without a word. Text frames already accept any
case. The SVG font selector now runs usvg default query with each named
family spelled the way the font database spells it; family order,
generics, weight, stretch and style are unchanged.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo
---
crates/images/src/lib.rs | 79 ++++++++++++++++++++++++++++++++++++----
1 file changed, 72 insertions(+), 7 deletions(-)
diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs
index 28a42522d..ece1697f1 100644
--- a/crates/images/src/lib.rs
+++ b/crates/images/src/lib.rs
@@ -73,14 +73,18 @@ fn svg_options() -> &'static usvg::Options<'static> {
}
/// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too),
-/// as it would in a text frame. A name that is no family's but a face's PostScript name (as
-/// Illustrator's SVG export writes them: `font-family="'MyriadPro-Regular'"`) sets the text in
-/// that face, whatever font-weight and font-style say: the name already picks the weight and
-/// style, and a family's other faces would contradict it. Families listed before it come first.
+/// as it would in a text frame, and matches in any case, as it does there. A name that is no
+/// family's but a face's PostScript name (as Illustrator's SVG export writes them:
+/// `font-family="'MyriadPro-Regular'"`) sets the text in that face, whatever font-weight and
+/// font-style say: the name already picks the weight and style, and a family's other faces would
+/// contradict it. Families listed before it come first.
fn select_font() -> usvg::FontSelectionFn<'static> {
- let bundled = usvg::FontResolver::default_font_selector();
+ use usvg::fontdb::{Family, Query, Stretch, Style, Weight};
Box::new(move |font, db| {
let fonts = designcraft_fonts::FontDb::global();
+ let spelled = |db: &usvg::fontdb::Database, name: &str| {
+ db.faces().flat_map(|f| &f.families).find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(n, _)| n.clone())
+ };
for family in font.families() {
if let usvg::FontFamily::Named(name) = family
&& !has_family(db, name)
@@ -103,11 +107,51 @@ fn select_font() -> usvg::FontSelectionFn<'static> {
return Some(id);
}
}
- // A family (or generic family) that matches: usvg's own selection.
+ // A family (or generic family) that matches: the query below.
_ => break,
}
}
- bundled(font, db)
+ // usvg's default query, but with each named family spelled as the database spells it:
+ // fontdb compares names exactly, so "DeJaVu SaNs" would quietly fall to the serif.
+ let names: Vec
From 8dff7ecdb04d3708a112bb42fc1cecc38664f3cb Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Tue, 20 Oct 2026 11:39:20 -0400
Subject: [PATCH 08/43] Skip CodeRabbit reviews on Dependabot PRs (#1386)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].
Co-authored-by: Claude Opus 5.5
---
.coderabbit.yaml | 6 ++++++
1 file changed, 6 insertions(+)
create mode 100644 .coderabbit.yaml
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..2408084cf
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
From 1420d8c79b43096efc659eee7a4db19fe5a1d6a5 Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Tue, 20 Oct 2026 19:52:28 -0600
Subject: [PATCH 09/43] refactor(pdf): share placed-PDF rendering (PDFR) (#876)
---
ASSETS.md | 25 ++
Cargo.lock | 19 +-
Cargo.toml | 16 +-
NOTICE | 29 +-
ROADMAP.md | 2 +
crates/engine/src/cmd/export.rs | 11 +-
crates/engine/src/cmd/file.rs | 3 +
crates/engine/src/cmd/links.rs | 3 +
crates/engine/tests/pdf_backend.rs | 349 ++++++++++++++++++
crates/render/Cargo.toml | 3 +-
crates/render/src/lib.rs | 137 +++++--
crates/render/src/pdf_bounds.rs | 6 +-
crates/render/src/pdf_layers.rs | 14 +-
crates/render/tests/pdf_geometry.rs | 61 +++
crates/render/tests/placed_pdf.rs | 8 +-
docs/architecture.md | 2 +
docs/pdf-backend.md | 40 +-
.../pdf-backend/hayro-cmap/LICENSE-APACHE | 177 +++++++++
licenses/pdf-backend/hayro-cmap/LICENSE-MIT | 7 +
licenses/pdf-backend/hayro-cmap/LICENSE.txt | 28 ++
.../hayro-interpret/LICENSE-APACHE | 177 +++++++++
.../pdf-backend/hayro-interpret/LICENSE-MIT | 7 +
.../hayro-interpret/assets/CGATS_LICENSE.txt | 116 ++++++
.../hayro-interpret/assets/LICENSE_FOXIT | 27 ++
.../pdf-backend/hayro-jbig2/LICENSE-APACHE | 177 +++++++++
licenses/pdf-backend/hayro-jbig2/LICENSE-MIT | 7 +
.../pdf-backend/hayro-syntax/LICENSE-APACHE | 177 +++++++++
licenses/pdf-backend/hayro-syntax/LICENSE-MIT | 7 +
licenses/pdf-backend/hayro/LICENSE-APACHE | 177 +++++++++
licenses/pdf-backend/hayro/LICENSE-MIT | 7 +
xtask/src/main.rs | 6 +-
xtask/src/pdf_deps.rs | 46 ++-
32 files changed, 1791 insertions(+), 80 deletions(-)
create mode 100644 crates/engine/tests/pdf_backend.rs
create mode 100644 crates/render/tests/pdf_geometry.rs
create mode 100644 licenses/pdf-backend/hayro-cmap/LICENSE-APACHE
create mode 100644 licenses/pdf-backend/hayro-cmap/LICENSE-MIT
create mode 100644 licenses/pdf-backend/hayro-cmap/LICENSE.txt
create mode 100644 licenses/pdf-backend/hayro-interpret/LICENSE-APACHE
create mode 100644 licenses/pdf-backend/hayro-interpret/LICENSE-MIT
create mode 100644 licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt
create mode 100644 licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT
create mode 100644 licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE
create mode 100644 licenses/pdf-backend/hayro-jbig2/LICENSE-MIT
create mode 100644 licenses/pdf-backend/hayro-syntax/LICENSE-APACHE
create mode 100644 licenses/pdf-backend/hayro-syntax/LICENSE-MIT
create mode 100644 licenses/pdf-backend/hayro/LICENSE-APACHE
create mode 100644 licenses/pdf-backend/hayro/LICENSE-MIT
diff --git a/ASSETS.md b/ASSETS.md
index 2731f47bf..40e8a97b4 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -69,3 +69,28 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the DesignCraft app icon (`assets/app-icon/designcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | DesignCraft macOS DMG window background for the Finder window; `packaging/macos/dmg/generate.py` renders it to `background.tiff` (2026-10-08) |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
+
+## Assets bundled by the PDF backend
+
+These assets were already enabled by Hayro’s default font and CMap features. The pinned
+backend keeps the same files; hashes and upstream licence notices are retained here.
+
+| Crate / file | Author | Source | Licence | SHA-256 | Use |
+| --- | --- | --- | --- | --- | --- |
+| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
+| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by the PDF compatibility exception |
+| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by the PDF compatibility exception |
diff --git a/Cargo.lock b/Cargo.lock
index 50caf06dd..a1861d0b3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -832,8 +832,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
[[package]]
name = "craft-pdf"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
+source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
dependencies = [
+ "bytemuck",
+ "hayro",
+ "hayro-interpret",
+ "hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
@@ -1181,16 +1185,17 @@ dependencies = [
name = "designcraft-render"
version = "0.10.0"
dependencies = [
+ "craft-pdf",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-fonts",
"designcraft-geom",
"designcraft-images",
- "hayro",
"image",
"log",
"serde",
+ "thiserror 2.0.21",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
]
@@ -2105,7 +2110,7 @@ dependencies = [
[[package]]
name = "hayro"
version = "0.7.1"
-source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
+source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
dependencies = [
"bytemuck",
"hayro-interpret",
@@ -2135,7 +2140,7 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
-source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
+source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -2153,7 +2158,7 @@ dependencies = [
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
-source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
+source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
dependencies = [
"fearless_simd",
"hayro-ccitt",
@@ -2177,7 +2182,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
-source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
+source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -2541,7 +2546,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
-source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
+source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
dependencies = [
"base64",
"bumpalo",
diff --git a/Cargo.toml b/Cargo.toml
index 7772fb441..2b6ee0c7b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,8 +11,8 @@ rust-version = "1.95"
repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
-# Shared writer; the immutable revision must be published before this consumer.
-craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false }
+# Shared reader and writer; publish the immutable revision before this consumer.
+craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.1.0", default-features = false }
# internal
designcraft-geom = { path = "crates/geom" }
designcraft-color = { path = "crates/color" }
@@ -66,7 +66,6 @@ krilla-svg = "=0.8.1"
flo_curves = "0.8"
# QR codes (Object › Generate QR Code); MIT.
qrcodegen = "1.8"
-hayro = "0.7"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, tracking its main branch
@@ -116,10 +115,9 @@ debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
+krilla = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.8.2" }
winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
-krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
-# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists.
-hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
-hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
-hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
-hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
+hayro = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.1" }
+hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.0" }
+hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" }
+hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.3.0" }
diff --git a/NOTICE b/NOTICE
index f4e3184f6..f1e997156 100644
--- a/NOTICE
+++ b/NOTICE
@@ -81,12 +81,35 @@ list (author, source, license) is in ASSETS.md.
packages as OFL-.txt. Builds without CRAFT_FONTS_DIR do not include them.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
- hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2
- (Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at
- the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0).
+ (Apache-2.0 OR MIT) are fetched from the pinned craft-libs source documented below.
hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license
(vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0.
Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively
-licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
+licensed (MIT/Apache-2.0/BSD/ISC/Zlib), fetched through crates.io or the pinned source below.
No Adobe icons, images or artwork are included.
+
+PDF backend source
+
+The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
+are fetched from https://github.com/storytold/craft-libs at revision
+89dcbb3060377a26c99a6d80caa16084366704c4. The four source snapshots were transferred
+from https://github.com/storytold/pdfcraft at revision
+7437bde378f43856eada40af25b983812e615e8a, then patched in craft-libs. Their upstream project is
+https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
+crate's upstream licence files. The patch ledger is at
+https://github.com/storytold/craft-libs/blob/89dcbb3060377a26c99a6d80caa16084366704c4/vendor/README.craft-reader.md.
+Per-crate vendor/SOURCE.json files record transferred and local source hashes; asset
+origins and unchanged per-file hashes are recorded separately in ASSETS.md.
+
+Assets already bundled through Hayro's default features retain their licences:
+- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
+ Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see
+ licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT.
+- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see
+ licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt.
+- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause,
+ non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
+ Adobe and contributor names do not endorse or promote this product.
+Per-file hashes and sources are recorded in ASSETS.md.
diff --git a/ROADMAP.md b/ROADMAP.md
index 0128924c0..a7b5105d3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -638,3 +638,5 @@ Newest first.
The shared craft-installer UI preserves this revision's install options and provides persistent installation, repair and Windows Settings removal completion screens. Silent modes stay unattended. Earlier Windows acceptance is recorded in packaging/windows/README.md; normal CI and production-profile upgrade qualification against pinned current releases are in progress. No release or PR has been published.
- **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change.
+
+- PDF reader infrastructure: pinned shared parsing/rasterization, transformed page-box geometry and explicit failures for known incomplete layered-PDF exports. Application ownership and parity targets are unchanged.
diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs
index 8bbe41a8a..36fd3d52a 100644
--- a/crates/engine/src/cmd/export.rs
+++ b/crates/engine/src/cmd/export.rs
@@ -659,11 +659,11 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I
}
/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images.
-fn rasterize_layered(d: &designcraft_doc::Document) -> (Option, usize) {
+fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> {
let ids: Vec =
d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect();
if ids.is_empty() {
- return (None, 0);
+ return Ok((None, 0));
}
let mut out = d.clone();
let mut n = 0;
@@ -678,7 +678,8 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option (Option Result {
let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi));
// Placed PDFs with hidden layers go out as images showing just their visible layers.
let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0);
- let (layered, layer_count) = rasterize_layered(base);
+ let (layered, layer_count) = rasterize_layered(base)?;
let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base);
let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache);
let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2);
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 973262937..da38b8132 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -629,6 +629,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result {
})
.transpose()?;
// Image Import Options: which page of a PDF (1-based).
+ if designcraft_render::is_pdf(&bytes) {
+ designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?;
+ }
let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) {
Some(n) if designcraft_render::is_pdf(&bytes) => {
let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1);
diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs
index c6e9e7566..6623b6cb8 100644
--- a/crates/engine/src/cmd/links.rs
+++ b/crates/engine/src/cmd/links.rs
@@ -398,6 +398,9 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi
fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> {
let path = path.as_ref();
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
+ if designcraft_render::is_pdf(&b) {
+ designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?;
+ }
let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{}: unsupported or corrupt image", path.display())))?;
Ok((b, px))
}
diff --git a/crates/engine/tests/pdf_backend.rs b/crates/engine/tests/pdf_backend.rs
new file mode 100644
index 000000000..6b4f93081
--- /dev/null
+++ b/crates/engine/tests/pdf_backend.rs
@@ -0,0 +1,349 @@
+//! Synthetic placement/export regressions for PDF backend updates.
+
+use serde_json::json;
+
+use designcraft_engine::Session;
+use designcraft_engine::cmd::{base64_decode, base64_encode};
+
+fn pdf(objects: &[String]) -> Vec {
+ let mut bytes = b"%PDF-1.7\n".to_vec();
+ let mut offsets = vec![0];
+ for (i, object) in objects.iter().enumerate() {
+ offsets.push(bytes.len());
+ bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = bytes.len();
+ bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", offsets.len()).as_bytes());
+ for offset in offsets.iter().skip(1) {
+ bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
+ }
+ bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len()).as_bytes());
+ bytes
+}
+
+fn layered_pdf(second_name: &str) -> Vec {
+ layered_pdf_with_box(second_name, "")
+}
+
+fn layered_pdf_with_box(second_name: &str, page_box: &str) -> Vec {
+ let content = "/OC /a BDC 0 0 1 rg 10 10 35 80 re f EMC /OC /b BDC 1 0 0 rg 55 10 35 80 re f EMC";
+ pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ format!(
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] {page_box} /Resources << /Properties << /a 5 0 R /b 6 0 R >> >> /Contents 4 0 R >>"
+ ),
+ format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
+ "<< /Type /OCG /Name (Blue) >>".into(),
+ format!("<< /Type /OCG /Name ({second_name}) >>"),
+ ])
+}
+
+fn rendered(bytes: &[u8]) -> designcraft_render::Rendered {
+ let pm = designcraft_render::render_pdf_page(bytes, 0, 100).expect("render synthetic PDF");
+ designcraft_render::Rendered {
+ width: pm.width().into(),
+ height: pm.height().into(),
+ pixels: pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(),
+ }
+}
+
+// Optional local evidence output; no binary fixtures are checked in.
+fn evidence(name: &str, bytes: &[u8]) {
+ if let Some(dir) = std::env::var_os("CRAFT_PDF_EVIDENCE_DIR") {
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(std::path::Path::new(&dir).join(name), bytes).unwrap();
+ }
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn placed_pdf_preserves_transparent_background_and_layer_choices() {
+ let bytes = layered_pdf("Red");
+ let img = rendered(&bytes);
+ assert_eq!(img.pixel(2, 2), [0, 0, 0, 0]);
+ assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
+ assert_eq!(img.pixel(75, 50), [255, 0, 0, 255]);
+ let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).unwrap();
+ let img = rendered(&hidden);
+ assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
+ assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
+ evidence("layers-hidden.png", &img.to_png());
+ evidence("layers-source.pdf", &bytes);
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn duplicate_layer_names_keep_name_based_hiding() {
+ let bytes = layered_pdf("Blue");
+ assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2);
+ let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Blue".into()]).unwrap();
+ let img = rendered(&hidden);
+ assert_eq!(img.pixel(25, 50), [0, 0, 0, 0]);
+ assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
+ // A second render of the original must not inherit the hidden configuration.
+ assert_eq!(rendered(&bytes).pixel(25, 50), [0, 0, 255, 255]);
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn crop_box_and_rotation_keep_placed_dimensions() {
+ let bytes = pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 100] /CropBox [10 20 110 80] /Rotate 90 >>".into(),
+ ]);
+ assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(1));
+ assert_eq!(designcraft_render::pdf_page_size(&bytes, 0), Some((60.0, 100.0)));
+ let img = rendered(&bytes);
+ assert_eq!((img.width, img.height), (60, 100));
+ assert_eq!(img.pixel(20, 20), [0, 0, 0, 0]);
+}
+
+fn exported(hidden: bool) -> Vec {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
+ session.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap();
+ session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap();
+ session.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap();
+ let placed = session
+ .execute(
+ "file.place",
+ &json!({
+ "base64": base64_encode(&layered_pdf("Red")), "name": "layers.pdf", "x": 0, "y": 0, "width": 100
+ }),
+ )
+ .unwrap();
+ if hidden {
+ 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
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn hidden_layer_export_composites_over_underlying_artwork() {
+ let bytes = exported(true);
+ let img = rendered(&bytes);
+ let right = img.pixel(75, 50);
+ assert!(right[..3].iter().all(|c| *c < 45), "hidden area must show the black backdrop, not opaque white: {right:?}");
+ let left = img.pixel(25, 50);
+ assert!(left[2] > 180 && left[0] < 80, "visible blue layer: {left:?}");
+ evidence("hidden-export.pdf", &bytes);
+ evidence("hidden-export.png", &img.to_png());
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn ordinary_placed_pages_still_embed_as_vectors() {
+ use hayro_syntax::object::Name;
+ let bytes = exported(false);
+ let parsed = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
+ let subtypes: Vec<_> = parsed
+ .objects()
+ .into_iter()
+ .filter_map(|o| o.into_stream())
+ .filter_map(|s| s.dict().get::("Subtype").map(|n| n.as_str().to_owned()))
+ .collect();
+ assert!(subtypes.iter().any(|s| s == "Form"), "placed PDF form: {subtypes:?}");
+ assert!(!subtypes.iter().any(|s| s == "Image"), "no rasterization: {subtypes:?}");
+ let img = rendered(&bytes);
+ let right = img.pixel(75, 50);
+ assert!(right[0] > 180 && right[1] < 80, "visible red vector layer: {right:?}");
+ evidence("vector-export.pdf", &bytes);
+ evidence("vector-export.png", &img.to_png());
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn oversized_image_does_not_silently_disappear_from_layered_export() {
+ // Just above the backend's 2^28-pixel cap. The stream is one byte; no large
+ // fixture or large image allocation is needed to exercise the refusal.
+ let content = "/OC /a BDC 0 0 1 rg 10 10 35 80 re f EMC q 100 0 0 100 0 0 cm /Im Do Q";
+ let bytes = pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /Order [6 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 6 0 R >> /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(),
+ format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
+ "<< /Type /XObject /Subtype /Image /Width 32769 /Height 8192 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nX\nendstream".into(),
+ "<< /Type /OCG /Name (Blue) >>".into(),
+ ]);
+ let mut session = Session::new();
+ session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
+ let placed = session
+ .execute(
+ "file.place",
+ &json!({
+ "base64": base64_encode(&bytes), "name": "oversized-scan.pdf", "x": 0, "y": 0, "width": 100
+ }),
+ )
+ .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!({}));
+ 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();
+ assert!(error.contains("oversized-scan.pdf") && error.contains("image"), "{error}");
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn failed_layer_rewrite_aborts_export_instead_of_revealing_hidden_content() {
+ // A stored layer choice can outlive relinking to a PDF with no layers.
+ let bytes = pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>".into(),
+ ]);
+ assert!(designcraft_render::render_pdf_page(&bytes, 0, 100).is_some());
+ assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none());
+ let mut session = Session::new();
+ session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
+ let placed =
+ session.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "missing-layers.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
+ session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
+ let before = session.execute("file.serialize", &json!({})).unwrap();
+ let result = session.execute("file.exportPdf", &json!({}));
+ assert!(result.is_err(), "failed layer hiding must not export the original visible layers");
+ let error = result.unwrap_err().to_string();
+ assert!(error.contains("missing-layers.pdf") && error.contains("layer"), "{error}");
+ assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before);
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn placed_standard_font_text_renders_ink_on_a_transparent_background() {
+ let content = "BT /F1 18 Tf 10 40 Td (PDF text) Tj ET";
+ let bytes = pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".into(),
+ format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
+ ]);
+ let img = rendered(&bytes);
+ let ink = (40..65).flat_map(|y| (5..95).map(move |x| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[3] > 100).count();
+ assert!(ink > 100, "standard-font glyphs must paint visible ink: {ink}");
+ assert_eq!(img.pixel(2, 2), [0, 0, 0, 0]);
+ assert_eq!(img.pixel(50, 80), [0, 0, 0, 0]);
+ assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &[], None).is_ok());
+ evidence("standard-font-text.png", &img.to_png());
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn unavailable_placed_page_reports_the_page_and_recovery_action() {
+ let error = designcraft_render::pdf_page_png_checked(&layered_pdf("Red"), 2, 100, &["Red".into()], None).unwrap_err();
+ assert!(matches!(error, designcraft_render::PdfRasterError::MissingPage { page: 3 }));
+ assert!(error.to_string().contains("place the replacement PDF again"));
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn hidden_layers_work_with_an_xref_stream_and_compressed_catalog() {
+ // Reuse the tiny synthetic objects, but put the catalog in an object stream
+ // and index everything with a binary cross-reference stream.
+ let classic = String::from_utf8(layered_pdf("Red")).unwrap();
+ let objects: Vec<&str> = (1..=6).map(|i| classic.split_once(&format!("{i} 0 obj\n")).unwrap().1.split_once("\nendobj").unwrap().0).collect();
+ let mut bytes = b"%PDF-1.7\n".to_vec();
+ let mut offsets = [0u32; 9];
+ for (i, object) in objects.iter().enumerate().skip(1) {
+ offsets[i + 1] = u32::try_from(bytes.len()).unwrap();
+ bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
+ }
+ offsets[7] = u32::try_from(bytes.len()).unwrap();
+ let catalog = format!("1 0 {}", objects[0]);
+ bytes.extend_from_slice(
+ format!("7 0 obj\n<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n{catalog}\nendstream\nendobj\n", catalog.len()).as_bytes(),
+ );
+ offsets[8] = u32::try_from(bytes.len()).unwrap();
+ let mut entries = Vec::new();
+ for (i, offset) in offsets.iter().enumerate() {
+ let (kind, field, generation): (u8, u32, u16) = match i {
+ 0 => (0, 0, 65535),
+ 1 => (2, 7, 0),
+ _ => (1, *offset, 0),
+ };
+ entries.push(kind);
+ entries.extend_from_slice(&field.to_be_bytes());
+ entries.extend_from_slice(&generation.to_be_bytes());
+ }
+ bytes.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 9 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", entries.len()).as_bytes());
+ bytes.extend_from_slice(&entries);
+ bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[8]).as_bytes());
+ assert_eq!(rendered(&bytes).pixel(75, 50), [255, 0, 0, 255]);
+ let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).unwrap();
+ let img = rendered(&hidden);
+ assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
+ assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
+ assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &["Red".into()], None).is_ok());
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn trailing_whitespace_does_not_prevent_layer_hiding() {
+ let mut bytes = layered_pdf("Red");
+ bytes.extend_from_slice(&[b' '; 96]);
+ let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).expect("trailing whitespace is accepted by the layer rewriter");
+ let img = rendered(&hidden);
+ assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
+ assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
+ assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &["Red".into()], None).is_ok());
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn failed_content_decode_aborts_layered_export_without_changing_the_document() {
+ for filter in ["FlateDecode", "DCTDecode"] {
+ let bytes = pdf(&[
+ "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [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!("<< /Filter /{filter} /Length 1 >>\nstream\nx\nendstream"),
+ "<< /Type /OCG /Name (Blue) >>".into(),
+ ]);
+ let mut session = Session::new();
+ session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
+ let placed = session
+ .execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "broken-content.pdf", "x": 0, "y": 0, "width": 100}))
+ .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();
+ assert!(error.contains("broken-content.pdf") && error.contains("content"), "{filter}: {error}");
+ assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "{filter}");
+ }
+}
+
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn hidden_layer_export_keeps_the_placed_trim_box() {
+ let bytes = layered_pdf_with_box("Red", "/TrimBox [0 0 50 100]");
+ let mut session = Session::new();
+ session.execute("file.new", &json!({"width": 50, "height": 100, "pages": 1})).unwrap();
+ session.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap();
+ let placed = session
+ .execute(
+ "file.place",
+ &json!({"base64": base64_encode(&bytes), "name": "trimmed-layers.pdf", "pdfCrop": "trim", "x": 0, "y": 0, "width": 100}),
+ )
+ .unwrap();
+ session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
+ let before = session.execute("file.serialize", &json!({})).unwrap();
+ let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap();
+ let exported = base64_decode(report["base64"].as_str().unwrap());
+ let img = rendered(&exported);
+ assert_eq!((img.width, img.height), (50, 100));
+ // Omitting the shown box squeezes the whole page into the trimmed graphic,
+ // moving the blue rectangle's right edge from x=45 to x=22.5.
+ let blue = img.pixel(35, 50);
+ assert!(blue[2] > 180 && blue[0] < 80, "the trim box keeps the visible layer at its placed coordinates: {blue:?}");
+ assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before);
+ evidence("trimmed-layer-export.pdf", &exported);
+}
diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml
index 5ababd51e..8cd446af1 100644
--- a/crates/render/Cargo.toml
+++ b/crates/render/Cargo.toml
@@ -17,9 +17,10 @@ designcraft-compose = { workspace = true }
vello_cpu = { workspace = true }
vello_common = { workspace = true }
image = { workspace = true }
-hayro = { workspace = true }
+craft-pdf = { workspace = true, features = ["raster"] }
designcraft-images = { workspace = true }
log = { workspace = true }
+thiserror = { workspace = true }
serde = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index c42d8c897..6823fc006 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -1235,29 +1235,93 @@ pub fn is_pdf(bytes: &[u8]) -> bool {
/// Page count of a PDF.
pub fn pdf_page_count(bytes: &[u8]) -> Option {
- let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
+ let pdf = craft_pdf::open_slice(bytes, None).ok()?;
Some(pdf.pages().len())
}
/// Size of PDF page `page` (0-based), in points, as it shows (crop box, rotation applied).
pub fn pdf_page_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
- let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
+ let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
Some((w as f64, h as f64))
}
-/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off;
-/// only the `shown` part of it ([`designcraft_doc::Asset::shown_box`]) when given.
+/// A placed page could not be rasterized completely for export.
+#[derive(Debug, thiserror::Error)]
+pub enum PdfRasterError {
+ #[error("{0}")]
+ Input(#[source] craft_pdf::OpenError),
+ #[error("{0}")]
+ Render(#[from] craft_pdf::raster::Error),
+ #[error("the PDF page could not be read or rendered")]
+ InvalidPage,
+ #[error("PDF page {page} is unavailable; choose an existing page in Image Import Options or place the replacement PDF again")]
+ MissingPage { page: usize },
+ #[error("the selected PDF layers could not be hidden")]
+ LayerRewrite,
+ #[error("an image could not be decoded or exceeded the PDF renderer's resource limits; repair or replace this placed PDF before exporting again")]
+ ImageDecode,
+ #[error("the PDF renderer encountered an unsupported font")]
+ UnsupportedFont,
+ #[error(
+ "the PDF renderer could not read all page content or reached its resource limits; repair or replace this placed PDF before exporting again"
+ )]
+ ContentTruncated,
+}
+
+/// Reject oversized PDFs before placement, layer rewriting or output allocation.
+pub fn check_pdf_input_size(size: usize) -> Result<(), PdfRasterError> {
+ craft_pdf::check_input_size(size).map_err(PdfRasterError::Input)
+}
+
+/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off.
+/// Only the `shown` part ([`designcraft_doc::Asset::shown_box`]) is kept when given.
+/// Returns `None` on the same failures as [`pdf_page_png_checked`].
pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Option> {
- let data = if hidden.is_empty() { bytes.to_vec() } else { pdf_hide_layers(bytes, hidden).unwrap_or_else(|| bytes.to_vec()) };
- let pm = render_pdf_page(&data, page, max_side)?;
+ pdf_page_png_checked(bytes, page, max_side, hidden, shown).ok()
+}
+
+/// Rasterize a placed page for export, preserving available interpreter diagnostics.
+/// This cannot detect backend approximations that do not emit an interpreter warning.
+pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result, PdfRasterError> {
+ check_pdf_input_size(bytes.len())?;
+ let data = if hidden.is_empty() {
+ std::borrow::Cow::Borrowed(bytes)
+ } else {
+ std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?)
+ };
+ let raster = render_pdf_raster(&data, page, max_side)?;
+ if raster.diagnostics.image_decode_failed {
+ return Err(PdfRasterError::ImageDecode);
+ }
+ if raster.diagnostics.unsupported_font {
+ return Err(PdfRasterError::UnsupportedFont);
+ }
+ if raster.diagnostics.content_truncated {
+ return Err(PdfRasterError::ContentTruncated);
+ }
+ let pm = raster_pixmap(&raster);
let pm = match shown {
- Some(frac) => crop_pixmap(&pm, frac)?,
+ Some(frac) => crop_pixmap(&pm, frac).ok_or(PdfRasterError::InvalidPage)?,
None => pm,
};
- let pixels: Vec = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect();
- Some(Rendered { width: pm.width() as u32, height: pm.height() as u32, pixels }.to_png())
+ let pixels = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect();
+ Ok(Rendered { width: u32::from(pm.width()), height: u32::from(pm.height()), pixels }.to_png())
+}
+
+/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display).
+/// Export callers that need failure diagnostics use [`pdf_page_png_checked`].
+pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option {
+ #[cfg(test)]
+ PDF_RENDERS.with(|n| n.set(n.get() + 1));
+ let raster = render_pdf_raster(bytes, page, max_side).ok()?;
+ Some(raster_pixmap(&raster))
+}
+
+fn raster_pixmap(raster: &craft_pdf::raster::Raster) -> Pixmap {
+ let data = raster.bytes().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect();
+ Pixmap::from_parts(data, raster.width as u16, raster.height as u16)
}
#[cfg(test)]
@@ -1266,21 +1330,46 @@ thread_local! {
static PDF_RENDERS: std::cell::Cell = const { std::cell::Cell::new(0) };
}
-/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display; PDF
-/// export embeds the page as vectors).
-pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option {
- #[cfg(test)]
- PDF_RENDERS.with(|n| n.set(n.get() + 1));
- let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
- let p = pdf.pages().get(page)?;
+fn render_pdf_raster(bytes: &[u8], page: usize, max_side: u32) -> Result {
+ let pdf = craft_pdf::open_slice(bytes, None).map_err(|e| match e {
+ craft_pdf::OpenError::Load(_) => PdfRasterError::InvalidPage,
+ other => PdfRasterError::Input(other),
+ })?;
+ let p = pdf.pages().get(page).ok_or(PdfRasterError::MissingPage { page: page.saturating_add(1) })?;
let (w, h) = p.render_dimensions();
let scale = (max_side as f32 / w.max(h).max(1.0)).min(8.0);
- let rs = hayro::RenderSettings { x_scale: scale, y_scale: scale, ..Default::default() };
- let pm = hayro::render(p, &hayro::RenderCache::new(), &hayro::hayro_interpret::InterpreterSettings::default(), &rs);
- let (pw, ph) = (pm.width(), pm.height());
- let data: Vec =
- pm.data_as_u8_slice().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect();
- Some(Pixmap::from_parts(data, pw, ph))
+ craft_pdf::raster::render(
+ p,
+ &craft_pdf::raster::RenderCache::new(),
+ &craft_pdf::interpret::InterpreterSettings::default(),
+ &craft_pdf::raster::Options { scale: [scale; 2], ..Default::default() },
+ )
+ .map_err(PdfRasterError::Render)
+}
+
+/// A page box of a PDF page (`crop`, `trim`, `bleed`, `art`, `media`) as (x, y, w, h) in points
+/// from the top-left of the page as rendered (its visible crop box); missing boxes fall back to the
+/// crop box, as PDF readers do.
+pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, f64, f64)> {
+ use craft_pdf::syntax::object::{Rect as PRect, dict::keys};
+ let pdf = craft_pdf::open_slice(bytes, None).ok()?;
+ let p = pdf.pages().get(page)?;
+ let visible = p.intersected_crop_box();
+ let page_box = match kind {
+ "media" => p.media_box(),
+ "trim" | "bleed" | "art" => {
+ let key: &[u8] = match kind {
+ "trim" => keys::TRIM_BOX,
+ "bleed" => keys::BLEED_BOX,
+ _ => keys::ART_BOX,
+ };
+ p.raw().get::(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible)
+ }
+ _ => visible,
+ };
+ let transform = Affine::new(p.initial_transform(true).as_coeffs());
+ let b = transform.transform_rect_bbox(Rect::new(page_box.x0, page_box.y0, page_box.x1, page_box.y1));
+ b.is_finite().then_some((b.x0, b.y0, b.width(), b.height()))
}
/// The part of PDF page `page` that `crop` shows: its left, top, right and bottom edges as
@@ -1289,10 +1378,10 @@ pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option Option<([f64; 4], (f64, f64))> {
+ use craft_pdf::syntax::object::{Rect as PRect, dict::keys};
use designcraft_doc::PdfCrop;
- use hayro::hayro_syntax::object::{Rect as PRect, dict::keys};
const WHOLE: [f64; 4] = [0.0, 0.0, 1.0, 1.0];
- let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
+ let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
let (w, h) = (f64::from(w), f64::from(h));
diff --git a/crates/render/src/pdf_bounds.rs b/crates/render/src/pdf_bounds.rs
index 74665ae3c..17881df6e 100644
--- a/crates/render/src/pdf_bounds.rs
+++ b/crates/render/src/pdf_bounds.rs
@@ -2,8 +2,8 @@
//! of every painted path, glyph and image, each cut to the clip in force when it's painted.
//! Nothing is rasterized, so measuring a page costs a fraction of drawing it.
-use hayro::hayro_interpret::font::Glyph;
-use hayro::hayro_interpret::{
+use craft_pdf::interpret::font::Glyph;
+use craft_pdf::interpret::{
BlendMode, ClipPath, Context, Device, FillRule, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt,
SoftMask, TransformExt, interpret_page,
};
@@ -12,7 +12,7 @@ use vello_cpu::kurbo::{self, Affine, BezPath, Rect, Shape};
/// The bounds of what PDF page `page` draws, as fractions of the page as rendered (its visible
/// crop box, rotated as it displays): left, top, right, bottom. `None` when it draws nothing.
pub(crate) fn page_content_bounds(bytes: &[u8], page: usize) -> Option<[f64; 4]> {
- let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
+ let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
let (w, h) = (f64::from(w), f64::from(h));
diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs
index 344585e3b..bc6d864a7 100644
--- a/crates/render/src/pdf_layers.rs
+++ b/crates/render/src/pdf_layers.rs
@@ -5,8 +5,8 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
-use hayro::hayro_syntax::Pdf;
-use hayro::hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier};
+use craft_pdf::syntax::Pdf;
+use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier};
/// (object number, name, on by default) of each layer.
fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> {
@@ -28,7 +28,7 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> {
.filter_map(|r| {
let d = xref.get::>(ObjectIdentifier::from(r))?;
let name = d
- .get::>(b"Name")
+ .get::>(b"Name")
.map(|s| String::from_utf8_lossy(s.as_bytes()).to_string())
.unwrap_or_default();
let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) };
@@ -39,13 +39,13 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> {
/// The layers of a placed PDF: (name, visible by default).
pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> {
- Pdf::new(Arc::new(bytes.to_vec())).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default()
+ craft_pdf::open_slice(bytes, None).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default()
}
/// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file
/// has no such layers or can't be read.
pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> {
- let pdf = Pdf::new(Arc::new(bytes.to_vec())).ok()?;
+ let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let layers = ocgs(&pdf);
if layers.is_empty() {
return None;
@@ -74,8 +74,8 @@ pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> {
);
let xref_at = out.len();
let prev = {
- let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string();
- tail.rsplit("startxref").next().and_then(|t| t.split_whitespace().next()).and_then(|n| n.parse::().ok())?
+ let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(1024)..]).to_string();
+ tail.rsplit_once("startxref").and_then(|(_, t)| t.split_whitespace().next()).and_then(|n| n.parse::().ok())?
};
out.extend_from_slice(
format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<>\nstartxref\n{xref_at}\n%%EOF\n", root + 1)
diff --git a/crates/render/tests/pdf_geometry.rs b/crates/render/tests/pdf_geometry.rs
new file mode 100644
index 000000000..dcf9427e2
--- /dev/null
+++ b/crates/render/tests/pdf_geometry.rs
@@ -0,0 +1,61 @@
+fn page(width: f32, height: f32) -> Vec {
+ let content = format!("1 0 0 rg 0 0 {width} {height} re f");
+ format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes()
+}
+
+#[test]
+fn thin_pdf_page_has_visible_preview_and_export_pixels() {
+ let bytes = page(1000.0, 0.2);
+ let preview = designcraft_render::render_pdf_page(&bytes, 0, 700).unwrap();
+ assert_eq!((preview.width(), preview.height()), (700, 1));
+ assert!(preview.data().iter().any(|pixel| pixel.a > 0));
+ let png = designcraft_render::pdf_page_png_checked(&bytes, 0, 700, &[], None).unwrap();
+ let image = image::load_from_memory(&png).unwrap().to_rgba8();
+ assert_eq!(image.dimensions(), (700, 1));
+ assert!(image.pixels().any(|pixel| pixel[3] > 0));
+}
+
+#[test]
+fn empty_media_box_preserves_the_parsers_default_page_repair() {
+ let bytes = page(0.0, 0.0);
+ let missing = String::from_utf8(bytes.clone()).unwrap().replace("/MediaBox [0 0 0 0]", "");
+ let size = designcraft_render::pdf_page_size(&bytes, 0).unwrap();
+ assert!(size.0 > 0.0 && size.1 > 0.0);
+ assert_eq!(Some(size), designcraft_render::pdf_page_size(missing.as_bytes(), 0));
+ let preview = designcraft_render::render_pdf_page(&bytes, 0, 700).unwrap();
+ let png = designcraft_render::pdf_page_png_checked(&bytes, 0, 700, &[], None).unwrap();
+ let image = image::load_from_memory(&png).unwrap();
+ assert_eq!((image.width(), image.height()), (u32::from(preview.width()), u32::from(preview.height())));
+}
+
+#[test]
+fn pdf_input_limit_is_actionable_before_placement() {
+ let error = designcraft_render::check_pdf_input_size(craft_pdf::MAX_INPUT_BYTES + 1).unwrap_err();
+ assert!(matches!(error, designcraft_render::PdfRasterError::Input(_)));
+ assert!(error.to_string().contains("input limit"));
+}
+
+#[test]
+fn pdf_boxes_match_scaled_and_rotated_page_coordinates() {
+ for (rotation, size, trim, media) in [
+ (0, (200.0, 120.0), (20.0, 40.0, 140.0, 60.0), (-20.0, -40.0, 400.0, 200.0)),
+ (90, (120.0, 200.0), (20.0, 20.0, 60.0, 140.0), (-40.0, -20.0, 200.0, 400.0)),
+ (180, (200.0, 120.0), (40.0, 20.0, 140.0, 60.0), (-180.0, -40.0, 400.0, 200.0)),
+ (270, (120.0, 200.0), (40.0, 40.0, 60.0, 140.0), (-40.0, -180.0, 200.0, 400.0)),
+ ] {
+ let bytes = String::from_utf8(page(200.0, 100.0)).unwrap().replace(
+ "/MediaBox [0 0 200 100]",
+ &format!("/MediaBox [0 0 200 100] /CropBox [10 20 110 80] /TrimBox [20 30 90 60] /UserUnit 2 /Rotate {rotation}"),
+ );
+ let bytes = bytes.as_bytes();
+ assert_eq!(designcraft_render::pdf_page_size(bytes, 0), Some(size), "rotation {rotation}");
+ assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "crop"), Some((0.0, 0.0, size.0, size.1)), "rotation {rotation}");
+ assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "trim"), Some(trim), "rotation {rotation}");
+ assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "media"), Some(media), "rotation {rotation}");
+ assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "art"), Some((0.0, 0.0, size.0, size.1)), "missing box, rotation {rotation}");
+ }
+}
diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/placed_pdf.rs
index 234e43ba7..118453067 100644
--- a/crates/render/tests/placed_pdf.rs
+++ b/crates/render/tests/placed_pdf.rs
@@ -1,4 +1,4 @@
-//! Placed PDFs are drawn through pdfcraft's patched hayro crates. Calculator functions whose `idiv`
+//! Placed PDFs are drawn through craft-pdf's patched Hayro crates. Calculator functions whose `idiv`
//! divides by zero or overflows, stitching functions nested a hundred thousand deep, and mesh
//! shadings with points far off the page, ten thousand overlapping triangles or forty thousand
//! patches do not panic, overflow the stack or take more than a minute, and the rest of the page is
@@ -9,14 +9,14 @@ use std::sync::Arc;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
+use craft_pdf::interpret::Function;
+use craft_pdf::syntax::Pdf;
+use craft_pdf::syntax::object::{Object, ObjectIdentifier};
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, PdfCrop, Shape, SpreadRef};
use designcraft_geom::{Affine, Rect, shapes};
use designcraft_render::{Rendered, Renderer, pdf_crop_box, pdf_page_size};
-use hayro::hayro_interpret::Function;
-use hayro::hayro_syntax::Pdf;
-use hayro::hayro_syntax::object::{Object, ObjectIdentifier};
const WHITE: [u8; 4] = [255, 255, 255, 255];
const RED: [u8; 4] = [255, 0, 0, 255];
diff --git a/docs/architecture.md b/docs/architecture.md
index a90b75df3..6e0c4c66b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -113,3 +113,5 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci
| 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary |
| 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap |
| 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` |
+
+PDF parsing and placed-page rasterization use the pinned `craft-pdf` reader. DesignCraft retains document, placement, layer and export policy; dependency checks and bounds are described in [pdf-backend.md](pdf-backend.md).
diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md
index fcc6afeaa..4cbc985d3 100644
--- a/docs/pdf-backend.md
+++ b/docs/pdf-backend.md
@@ -1,9 +1,12 @@
-# PDF writing boundary
+# Shared PDF backend
-DesignCraft pins `craft-pdf` and its patched Krilla 0.8.2 backend to craft-libs revision
-`c3487320b2d1de657b860b3c8abb44e1f2b32bd8`. The `write-pdf` capability is required for
-placed PDF pages and book merging. The ordinary reader dependencies, `craft-ui` and
-`craft-print` revisions remain unchanged by this writer migration.
+DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the four Hayro reader crates to
+craft-libs revision `89dcbb3060377a26c99a6d80caa16084366704c4`. Existing `craft-ui` and `craft-print` revisions
+remain unchanged. The reader migration is reviewed on top of the separate writer change.
+
+## Writing
+
+The `write-pdf` capability is required for placed PDF pages and book merging.
The shared library supplies fallible font copying/preparation, metadata setup, version and
archival configuration, final serialization and a native unwind guard. DesignCraft retains
@@ -32,3 +35,30 @@ Tests cover metadata key presence, hostile page selection, successful PDF export
subprocess recovery for all three export entry points. Native/WASM export comparisons and
independent PDF parsing/rendering supplement the repository gates. Physical printer output,
Windows/Linux runtime and PDF/A/PDF/X certification remain separate acceptance work.
+
+## Reading
+
+Placed-PDF geometry, layers and raster output use `craft-pdf` with its explicit `raster`
+feature. The facade and four patched Hayro crates are pinned to one immutable craft-libs
+revision. The backend source was transferred from PdfCraft revision
+`7437bde378f43856eada40af25b983812e615e8a`; the per-crate `vendor/SOURCE.json` manifests and `vendor/README.craft-reader.md`
+record the transfer and subsequent craft-libs patches. Asset provenance and hashes
+are recorded separately in ASSETS.md.
+`cargo xtask pdf-deps` checks unique resolved packages, exact versions and sources.
+
+DesignCraft retains placement, layer-name policy, document state and the application render
+cache. Render output is premultiplied RGBA8. Page boxes use the same crop, rotation and
+UserUnit transform as raster output, including fractional placed-PDF dimensions.
+Layered-PDF export stops on failed layer rewriting, missing pages or reported incomplete
+content rather than saving an unchanged or incomplete placed page. Shared diagnostics only
+report known backend warnings; they do not certify all PDF fidelity.
+
+DesignCraft uses a 512 MiB reader input bound, checked before parsing or copying.
+Raster output has shared geometry/allocation bounds. Native unwind guards turn escaped
+backend panics into errors; WASM aborts and process-wide allocation failures are not recoverable.
+Application fonts, image assets, editing and export presets remain app-owned. Existing
+craft-ui/craft-print versions are preserved. The reader migration builds on the independently qualified writer change.
+
+The shared commit must be published before a fresh public checkout can resolve these pins.
+No sibling checkout is required. Licences and existing backend asset notices are recorded in
+NOTICE, ASSETS.md and licenses/pdf-backend. The backend's removed LAB.icc is not restored.
diff --git a/licenses/pdf-backend/hayro-cmap/LICENSE-APACHE b/licenses/pdf-backend/hayro-cmap/LICENSE-APACHE
new file mode 100644
index 000000000..4947287f7
--- /dev/null
+++ b/licenses/pdf-backend/hayro-cmap/LICENSE-APACHE
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro-cmap/LICENSE-MIT b/licenses/pdf-backend/hayro-cmap/LICENSE-MIT
new file mode 100644
index 000000000..ae3742de4
--- /dev/null
+++ b/licenses/pdf-backend/hayro-cmap/LICENSE-MIT
@@ -0,0 +1,7 @@
+Copyright (c) The Hayro Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro-cmap/LICENSE.txt b/licenses/pdf-backend/hayro-cmap/LICENSE.txt
new file mode 100644
index 000000000..2552cc990
--- /dev/null
+++ b/licenses/pdf-backend/hayro-cmap/LICENSE.txt
@@ -0,0 +1,28 @@
+Copyright 1990-2023 Adobe. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+
+Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+Neither the name of Adobe nor the names of its contributors may be
+used to endorse or promote products derived from this software without
+specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/licenses/pdf-backend/hayro-interpret/LICENSE-APACHE b/licenses/pdf-backend/hayro-interpret/LICENSE-APACHE
new file mode 100644
index 000000000..4947287f7
--- /dev/null
+++ b/licenses/pdf-backend/hayro-interpret/LICENSE-APACHE
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro-interpret/LICENSE-MIT b/licenses/pdf-backend/hayro-interpret/LICENSE-MIT
new file mode 100644
index 000000000..ae3742de4
--- /dev/null
+++ b/licenses/pdf-backend/hayro-interpret/LICENSE-MIT
@@ -0,0 +1,7 @@
+Copyright (c) The Hayro Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt b/licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt
new file mode 100644
index 000000000..550e190e5
--- /dev/null
+++ b/licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt
@@ -0,0 +1,116 @@
+CC0 1.0 Universal
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator and
+subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for the
+purpose of contributing to a commons of creative, cultural and scientific
+works ("Commons") that the public can reliably and without fear of later
+claims of infringement build upon, modify, incorporate in other works, reuse
+and redistribute as freely as possible in any form whatsoever and for any
+purposes, including without limitation commercial purposes. These owners may
+contribute to the Commons to promote the ideal of a free culture and the
+further production of creative, cultural and scientific works, or to gain
+reputation or greater distribution for their Work in part through the use and
+efforts of others.
+
+For these and/or other purposes and motivations, and without any expectation
+of additional consideration or compensation, the person associating CC0 with a
+Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
+and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
+and publicly distribute the Work under its terms, with knowledge of his or her
+Copyright and Related Rights in the Work and the meaning and intended legal
+effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not limited
+to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display, communicate,
+ and translate a Work;
+
+ ii. moral rights retained by the original author(s) and/or performer(s);
+
+ iii. publicity and privacy rights pertaining to a person's image or likeness
+ depicted in a Work;
+
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+
+ v. rights protecting the extraction, dissemination, use and reuse of data in
+ a Work;
+
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation thereof,
+ including any amended or successor version of such directive); and
+
+ vii. other similar, equivalent or corresponding rights throughout the world
+ based on applicable law or treaty, and any national implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention of,
+applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
+unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
+and Related Rights and associated claims and causes of action, whether now
+known or unknown (including existing as well as future claims and causes of
+action), in the Work (i) in all territories worldwide, (ii) for the maximum
+duration provided by applicable law or treaty (including future time
+extensions), (iii) in any current or future medium and for any number of
+copies, and (iv) for any purpose whatsoever, including without limitation
+commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
+the Waiver for the benefit of each member of the public at large and to the
+detriment of Affirmer's heirs and successors, fully intending that such Waiver
+shall not be subject to revocation, rescission, cancellation, termination, or
+any other legal or equitable action to disrupt the quiet enjoyment of the Work
+by the public as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason be
+judged legally invalid or ineffective under applicable law, then the Waiver
+shall be preserved to the maximum extent permitted taking into account
+Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
+is so judged Affirmer hereby grants to each affected person a royalty-free,
+non transferable, non sublicensable, non exclusive, irrevocable and
+unconditional license to exercise Affirmer's Copyright and Related Rights in
+the Work (i) in all territories worldwide, (ii) for the maximum duration
+provided by applicable law or treaty (including future time extensions), (iii)
+in any current or future medium and for any number of copies, and (iv) for any
+purpose whatsoever, including without limitation commercial, advertising or
+promotional purposes (the "License"). The License shall be deemed effective as
+of the date CC0 was applied by Affirmer to the Work. Should any part of the
+License for any reason be judged legally invalid or ineffective under
+applicable law, such partial invalidity or ineffectiveness shall not
+invalidate the remainder of the License, and in such case Affirmer hereby
+affirms that he or she will not (i) exercise any of his or her remaining
+Copyright and Related Rights in the Work or (ii) assert any associated claims
+and causes of action with respect to the Work, in either case contrary to
+Affirmer's express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+
+ b. Affirmer offers the Work as-is and makes no representations or warranties
+ of any kind concerning the Work, express, implied, statutory or otherwise,
+ including without limitation warranties of title, merchantability, fitness
+ for a particular purpose, non infringement, or the absence of latent or
+ other defects, accuracy, or the present or absence of errors, whether or not
+ discoverable, all to the greatest extent permissible under applicable law.
+
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without limitation
+ any person's Copyright and Related Rights in the Work. Further, Affirmer
+ disclaims responsibility for obtaining any necessary consents, permissions
+ or other rights required for any use of the Work.
+
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to this
+ CC0 or use of the Work.
+
+For more information, please see
+http://creativecommons.org/publicdomain/zero/1.0/
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT b/licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT
new file mode 100644
index 000000000..8b4ed6ddd
--- /dev/null
+++ b/licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT
@@ -0,0 +1,27 @@
+// Copyright 2014 PDFium Authors. All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are
+// met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above
+// copyright notice, this list of conditions and the following disclaimer
+// in the documentation and/or other materials provided with the
+// distribution.
+// * Neither the name of Google Inc. nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE b/licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE
new file mode 100644
index 000000000..4947287f7
--- /dev/null
+++ b/licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro-jbig2/LICENSE-MIT b/licenses/pdf-backend/hayro-jbig2/LICENSE-MIT
new file mode 100644
index 000000000..ae3742de4
--- /dev/null
+++ b/licenses/pdf-backend/hayro-jbig2/LICENSE-MIT
@@ -0,0 +1,7 @@
+Copyright (c) The Hayro Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro-syntax/LICENSE-APACHE b/licenses/pdf-backend/hayro-syntax/LICENSE-APACHE
new file mode 100644
index 000000000..4947287f7
--- /dev/null
+++ b/licenses/pdf-backend/hayro-syntax/LICENSE-APACHE
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro-syntax/LICENSE-MIT b/licenses/pdf-backend/hayro-syntax/LICENSE-MIT
new file mode 100644
index 000000000..ae3742de4
--- /dev/null
+++ b/licenses/pdf-backend/hayro-syntax/LICENSE-MIT
@@ -0,0 +1,7 @@
+Copyright (c) The Hayro Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro/LICENSE-APACHE b/licenses/pdf-backend/hayro/LICENSE-APACHE
new file mode 100644
index 000000000..4947287f7
--- /dev/null
+++ b/licenses/pdf-backend/hayro/LICENSE-APACHE
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/licenses/pdf-backend/hayro/LICENSE-MIT b/licenses/pdf-backend/hayro/LICENSE-MIT
new file mode 100644
index 000000000..ae3742de4
--- /dev/null
+++ b/licenses/pdf-backend/hayro/LICENSE-MIT
@@ -0,0 +1,7 @@
+Copyright (c) The Hayro Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index c89b7e8d7..e730feb51 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -20,12 +20,12 @@ usage: cargo xtask
commands:
assets check that every icon/image/font/asset is attributed in ASSETS.md
layers enforce the crate dependency layering (plan/architecture.md §3)
+ pdf-deps verify the pinned PDF backend, including Krilla dependencies
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
- pdf-deps verify the pinned shared PDF writer and patched Krilla
parity recompute the InDesign feature-parity summary in docs/parity-checklist.md
ico ...
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
@@ -37,11 +37,11 @@ fn main() -> ExitCode {
let result = match args.first().map(String::as_str) {
Some("assets") => assets::run(&root()),
Some("layers") => cmd_layers(),
+ Some("pdf-deps") => pdf_deps::run(&root()),
Some("wasm") => cmd_wasm(),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
- Some("pdf-deps") => pdf_deps::run(&root()),
Some("parity") => parity::run(&root()),
Some("ico") => ico::run(&rest),
Some("version") => version::run(&root(), &rest),
@@ -162,6 +162,7 @@ fn cmd_ci() -> Result<(), String> {
run(c, "cargo fmt --all -- --check")
}),
),
+ ("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
(
"clippy",
Box::new(|| {
@@ -178,7 +179,6 @@ fn cmd_ci() -> Result<(), String> {
run(c, "cargo test --workspace")
}),
),
- ("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
("assets", Box::new(|| assets::run(&root()))),
("layers", Box::new(cmd_layers)),
("wasm", Box::new(cmd_wasm)),
diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs
index d7dfb1cb7..bb2a4f01d 100644
--- a/xtask/src/pdf_deps.rs
+++ b/xtask/src/pdf_deps.rs
@@ -1,11 +1,18 @@
-//! Check the shared writer source, including transitive users such as krilla-svg.
+//! Check the shared reader source, including the parser reached through Krilla.
use std::path::Path;
use std::process::Command;
use serde_json::Value;
-const REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8";
-const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("krilla", "0.8.2")];
+const REV: &str = "89dcbb3060377a26c99a6d80caa16084366704c4";
+const PACKAGES: &[(&str, &str)] = &[
+ ("craft-pdf", "0.1.0"),
+ ("krilla", "0.8.2"),
+ ("hayro", "0.7.1"),
+ ("hayro-interpret", "0.7.0"),
+ ("hayro-syntax", "0.7.2"),
+ ("hayro-jbig2", "0.3.0"),
+];
pub fn run(root: &Path) -> Result<(), String> {
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
@@ -18,7 +25,7 @@ pub fn run(root: &Path) -> Result<(), String> {
}
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
validate(&metadata)?;
- println!("PDF writer: facade and patched Krilla resolve once to {REV}");
+ println!("PDF backend: facade, Krilla and Hayro resolve once to {REV}");
Ok(())
}
@@ -34,6 +41,15 @@ fn validate(metadata: &Value) -> Result<(), String> {
return Err(format!("{name}: expected {version} from {source}; got {package}"));
}
}
+ let nodes = metadata.pointer("/resolve/nodes").and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?;
+ let facade = packages.iter().find(|p| p["name"] == "craft-pdf").ok_or("craft-pdf: missing package")?;
+ let node = nodes.iter().find(|n| n["id"] == facade["id"]).ok_or("craft-pdf: missing resolved node")?;
+ let features = node["features"].as_array().ok_or("craft-pdf: missing features")?;
+ for required in ["read", "interpret", "raster", "write", "write-pdf"] {
+ if !features.iter().any(|f| f.as_str() == Some(required)) {
+ return Err(format!("craft-pdf: missing required {required} feature"));
+ }
+ }
Ok(())
}
@@ -43,17 +59,17 @@ mod tests {
use serde_json::json;
fn metadata() -> Value {
- json!({"packages": PACKAGES.iter().map(|(name, version)| json!({"name":name,"version":version,
- "source":format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})).collect::>()})
+ json!({"packages": PACKAGES.iter().map(|(name, version)| json!({"id":name,"name":name,"version":version,
+ "source":format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})).collect::>(), "resolve": {"nodes": [{"id": "craft-pdf", "features": ["read", "interpret", "raster", "write", "write-pdf"]}]}})
}
#[test]
- fn accepts_one_pinned_facade_and_writer() {
+ fn accepts_one_pinned_facade_and_reader() {
assert!(validate(&metadata()).is_ok());
}
#[test]
- fn rejects_unpatched_and_local_writer_sources() {
+ fn rejects_unpatched_and_local_reader_sources() {
for source in [Value::Null, json!("registry+https://github.com/rust-lang/crates.io-index")] {
let mut m = metadata();
m["packages"][1]["source"] = source;
@@ -63,7 +79,7 @@ mod tests {
#[test]
fn rejects_missing_duplicate_and_wrong_version_packages() {
- for index in [0, 1] {
+ for index in 0..PACKAGES.len() {
let mut m = metadata();
m["packages"][index]["version"] = json!("99.0.0");
assert!(validate(&m).is_err());
@@ -77,6 +93,18 @@ mod tests {
}
}
+ #[test]
+ fn rejects_missing_reader_or_writer_features() {
+ for index in 0..5 {
+ let mut m = metadata();
+ m["resolve"]["nodes"][0]["features"].as_array_mut().unwrap().remove(index);
+ assert!(validate(&m).is_err());
+ }
+ let mut m = metadata();
+ m["resolve"] = Value::Null;
+ assert!(validate(&m).is_err());
+ }
+
#[test]
fn rejects_facade_revision_drift_and_malformed_metadata() {
let mut m = metadata();
From 417ef40c4ce9004435750e568960937c8926b45a Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Tue, 20 Oct 2026 20:26:43 -0600
Subject: [PATCH 10/43] Pin winit to storytold/winit (same commit) (#1113)
The fork moved from skylartaylor/winit to the storytold org. The pinned
revision 3d51077f is unchanged; only the repository URL differs.
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index a1861d0b3..ef68540f4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5627,7 +5627,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
-source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
+source = "git+https://github.com/storytold/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
dependencies = [
"ahash",
"android-activity",
diff --git a/Cargo.toml b/Cargo.toml
index 2b6ee0c7b..5a936b211 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -116,7 +116,7 @@ debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.8.2" }
-winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
+winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
hayro = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" }
From 9826154db3cda783cc33c9418189cfd405691d10 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 13:56:26 -0400
Subject: [PATCH 11/43] ci: add SonarCloud scan workflow and config
---
sonar-project.properties | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 sonar-project.properties
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 000000000..e69de29bb
From b67232a6c8255cce824598f896fdecfe6223654d Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 13:57:06 -0400
Subject: [PATCH 12/43] ci: add SonarCloud scan workflow and config
---
sonar-project.properties | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/sonar-project.properties b/sonar-project.properties
index e69de29bb..33ab5fd47 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -0,0 +1,4 @@
+sonar.projectKey=storytold_designcraft
+sonar.organization=storytold
+sonar.sources=.
+sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/**
From 4d62a501c18fb3cf453c6e9f19f15cbc9c558e26 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 21 Oct 2026 13:57:30 -0400
Subject: [PATCH 13/43] Release 0.11.0
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 42 +++++++++++++++++++++---------------------
Cargo.toml | 2 +-
2 files changed, 22 insertions(+), 22 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index ef68540f4..ffca2b064 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -933,7 +933,7 @@ dependencies = [
[[package]]
name = "designcraft"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -955,7 +955,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -971,7 +971,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -983,7 +983,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1003,7 +1003,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-geom",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1054,7 +1054,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1063,7 +1063,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1076,7 +1076,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1088,7 +1088,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1101,7 +1101,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1116,7 +1116,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1131,7 +1131,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1144,7 +1144,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1166,7 +1166,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1183,7 +1183,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1202,7 +1202,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1211,7 +1211,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1223,7 +1223,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1250,7 +1250,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5801,7 +5801,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.10.0"
+version = "0.11.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 5a936b211..a19abf1a9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
-version = "0.10.0"
+version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
From c2dbcff51f85851cee12ffeee8e827a28808f93d Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 15:46:54 -0400
Subject: [PATCH 14/43] ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
---
.github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++
sonar-project.properties | 4 ----
2 files changed, 20 insertions(+), 4 deletions(-)
create mode 100644 .github/workflows/sonarcloud.yml
delete mode 100644 sonar-project.properties
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 000000000..ecee45a6d
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,20 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/sonar-project.properties b/sonar-project.properties
deleted file mode 100644
index 33ab5fd47..000000000
--- a/sonar-project.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-sonar.projectKey=storytold_designcraft
-sonar.organization=storytold
-sonar.sources=.
-sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/**
From e2a2502fba08d2c3f59fe5f75b9fafa0098c9f04 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 16:00:32 -0400
Subject: [PATCH 15/43] ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
---
sonar-project.properties | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 sonar-project.properties
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 000000000..f1095122d
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,2 @@
+sonar.projectKey=storytold_designcraft
+sonar.organization=storytold
From dec78db02c90a5060b4562036cff22d871f47059 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 19:36:16 -0400
Subject: [PATCH 16/43] security: enforce HTTPS on curl downloads (SonarCloud
S6506) (#1519)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
---
packaging/linux/package.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
index 2d7b80ee8..12ad8e0f2 100755
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -148,7 +148,7 @@ if has appimage; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
download="$TOOL.download"
- curl -fsSL -o "$download" \
+ curl --proto '=https' -fsSL -o "$download" \
"https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage"
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c -
chmod 755 "$download"
From 3b985e3813c1031b955db2a27d0fae0fcd9397e9 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 19:36:27 -0400
Subject: [PATCH 17/43] Use chrono instead of hand-rolled UTC conversion
(#1479)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Use chrono for UTC/calendar conversion instead of hand-rolled copies
Replaces the hand-rolled civil / civil_from_days / days_from_civil copies (the
146_097 / 719_468 magic numbers) across the logging timestamp, text-variable
dates, EPUB dcterms:modified, PDF metadata dates and PPTX core.xml dates with
chrono, added as a workspace dependency (default-features = false, std only, so
wasm is unaffected).
- apps/designcraft logging `timestamp` (private civil_from_days deleted).
- doc text variables: `civil` and `days_from_civil` use chrono::NaiveDate;
`format_date` and `parse_iso_date` keep their own format/parse logic and
still call these helpers.
- epub `iso8601`, pdf `civil`, pptx `civil`: UTC datetime via
chrono::DateTime::from_timestamp.
Output is byte-identical and pinned by the existing date, ISO-date, EPUB,
image, EPS and logging tests. The OOXML/EPUB dcterms timestamps keep their
exact strings. Each converted helper keeps a proleptic-Gregorian fallback for
dates outside chrono's NaiveDate range (and for the serial/overflow dates
parse_iso_date can hand days_from_civil), so no input changes or panics.
AGENTS.md now says time goes through chrono.
Follow-up to storytold/cadcraft#1785
* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed
logging.rs timestamp(): chrono's From unwraps, so a clock past
year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp
and read such a clock, or a pre-epoch clock, as the epoch like the old saturating
code did. Years past 9999 are written with {:04} like before, because %Y adds a
'+'. Tests cover both cases.
epub iso8601(): write the year with {:04} instead of %Y, matching the old
{year:04} output, so years past 9999 are not prefixed with '+'. Test added.
AGENTS.md: the "Time goes through chrono" rule now notes the narrow fallback
for dates outside chrono::NaiveDate's range, which crates/doc/src/vars.rs
retains (Howard Hinnant's algorithm).
* Don't build an unrepresentable SystemTime in the far-future test
UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.
* Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies
doc/vars.rs kept hand-rolled Hinnant `civil_fallback`/`days_from_civil_fallback`
copies for dates outside chrono's ±262143-year range, and AGENTS.md carved a
"narrow fallback" exception for them, which left calendar arithmetic outside
chrono after all.
Replace both copies with the Gregorian 400-year cycle (146_097 days): chrono
converts the day/year within one cycle and whole cycles are added back via i128
(so i64::MIN/MAX can't overflow). `civil` is reached by the system clock through
`format_date(now(), ..)`, so it must stay exact; `days_from_civil` only ever
sees a 4-digit parsed year via `parse_iso_date`, but it uses the same cycle form
for consistency. Both reproduce the pre-chrono proleptic Gregorian result, so
outputs are unchanged (verified against the old formula over wide ranges and at
the i64 extremes).
New test pins dates past chrono's range on both sides of the epoch, through the
public `format_date` and round-tripping `days_from_civil`/`civil`, with values
from the old formula. Removed the AGENTS.md "narrow fallback" exception.
epub/pdf/pptx `civil`/`iso8601` are left as-is: the PR already dropped their
hand-rolled copies for pure chrono, and their remaining epoch (`unwrap_or_default`)
fallback only triggers for a metadata/clock timestamp past year 262143 -- the
live-clock/file-mtime case FredApps accepted, with outputs that clamp the year
anyway (pptx 0..9999, PDF DateTime). No hand-rolled copy remains there to replace.
Tested: cargo test -p designcraft-doc; fmt --check; clippy --all-targets -D warnings.
* Carry an overflowing day into the next month again in parse_iso_date
parse_iso_date accepts any day 1..=31, and the old days_from_civil
carried a day past the end of its month into the next one (2019-02-30 is
2019-03-02). from_ymd_opt rejects such a day, and unwrap_or(0) turned it
into 1970-01-01. chrono now places the 1st of the month and the day is
added on, which restores the old normalization. Tested through days_from_civil
and parse_iso_date.
* time: preserve old out-of-range date conversion (no epoch fallback)
`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any
instant past chrono's year range to 1970-01-01, changing output the old
hand-rolled Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s
was 318857-05-20 17:46:40, not the epoch). Restore the old conversion domain
with a cycle-based helper that routes the calendar through chrono (the
calendar repeats every 400 years = 146_097 days, so month/day/time come from
chrono and only the year is carried, keeping every i64 exact), then apply each
format's existing year clamp afterwards.
- apps/designcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/epub/src/lib.rs: `iso8601` via `utc_parts`.
- crates/pdf/src/export.rs: `civil` cycle-based; `date_time` keeps its
0..=9999 year clamp.
- crates/pptx/src/package.rs: `civil` cycle-based, keeps its 0..=9999 year
clamp on the year only (month/day/time keep the real instant).
Pinned old outputs in tests (through the public helpers):
logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch)
epub 10_000_000_000_000 -> 318857-05-20T17:46:40Z
-10_000_000_000_000 -> -314918-08-13T06:13:20Z
i64::MAX -> 292277026596-12-04T15:30:07Z
i64::MIN -> -292277022657-01-27T08:29:52Z
pdf civil(10_000_000_000_000) -> (318857,5,20,17,46,40)
pptx civil(10_000_000_000_000) -> (9999,5,20,17,46,40) (year clamped)
* Don't build a year-318857 SystemTime unchecked in the logging test
Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.
* Name the 400-year range extension as the one exception to the time rule
The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.
* Say the library computes month and day; time-of-day is plain arithmetic
Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
---
AGENTS.md | 1 +
Cargo.lock | 14 ++++++
Cargo.toml | 2 +
apps/designcraft/Cargo.toml | 1 +
apps/designcraft/src/logging.rs | 57 +++++++++++++++++-------
crates/doc/Cargo.toml | 1 +
crates/doc/src/vars.rs | 78 +++++++++++++++++++++++++--------
crates/epub/Cargo.toml | 1 +
crates/epub/src/lib.rs | 45 ++++++++++++++-----
crates/pdf/Cargo.toml | 1 +
crates/pdf/src/export.rs | 46 +++++++++++++------
crates/pptx/Cargo.toml | 1 +
crates/pptx/src/package.rs | 39 +++++++++++------
13 files changed, 214 insertions(+), 73 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 65693656c..0be7fa87b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,6 +26,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: type tool caret navigation`).
diff --git a/Cargo.lock b/Cargo.lock
index ffca2b064..1180cf0b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -683,6 +683,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -936,6 +945,7 @@ name = "designcraft"
version = "0.11.0"
dependencies = [
"arboard",
+ "chrono",
"designcraft-engine",
"designcraft-ui-egui",
"eframe",
@@ -1005,6 +1015,7 @@ dependencies = [
name = "designcraft-doc"
version = "0.11.0"
dependencies = [
+ "chrono",
"designcraft-color",
"designcraft-geom",
"proptest",
@@ -1056,6 +1067,7 @@ dependencies = [
name = "designcraft-epub"
version = "0.11.0"
dependencies = [
+ "chrono",
"designcraft-color",
"designcraft-doc",
"zip",
@@ -1146,6 +1158,7 @@ dependencies = [
name = "designcraft-pdf"
version = "0.11.0"
dependencies = [
+ "chrono",
"craft-pdf",
"designcraft-color",
"designcraft-compose",
@@ -1168,6 +1181,7 @@ dependencies = [
name = "designcraft-pptx"
version = "0.11.0"
dependencies = [
+ "chrono",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
diff --git a/Cargo.toml b/Cargo.toml
index a19abf1a9..0e728e87f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,6 +39,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
rayon = "1"
unicode-bidi = "0.3"
diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml
index 7dc83bc55..81a267aa9 100644
--- a/apps/designcraft/Cargo.toml
+++ b/apps/designcraft/Cargo.toml
@@ -18,6 +18,7 @@ egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for designcraft.exe; build.rs only uses it when the target is Windows.
diff --git a/apps/designcraft/src/logging.rs b/apps/designcraft/src/logging.rs
index d3e0dbea7..5fe70a749 100644
--- a/apps/designcraft/src/logging.rs
+++ b/apps/designcraft/src/logging.rs
@@ -90,27 +90,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
+/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
- let secs = since.as_secs();
- let (days, rem) = (secs / 86_400, secs % 86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
+ // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
+ // past `i64::MAX` seconds saturates rather than falling back to the epoch.
+ let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
+ let (y, t) = utc_parts(secs, since.subsec_nanos());
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days + 719_468;
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + u64::from(m <= 2);
- (y, m, d)
+/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
+/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
+/// so month, day and time of day come from chrono and only the year is carried, which keeps every
+/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
+fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -317,6 +326,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // Past chrono's year range still converts via the 400-year cycle (the old code did too,
+ // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
+ // SystemTime can't hold this instant, so it is only checked where it exists.
+ // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
+ // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
+ // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
}
#[test]
diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml
index ed7430db7..1d3da4062 100644
--- a/crates/doc/Cargo.toml
+++ b/crates/doc/Cargo.toml
@@ -14,6 +14,7 @@ designcraft-color = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+chrono = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs
index 1ea4f8ced..0a3f5ea46 100644
--- a/crates/doc/src/vars.rs
+++ b/crates/doc/src/vars.rs
@@ -126,28 +126,46 @@ pub fn now() -> i64 {
}
}
-/// (year, month 1–12, day 1–31) from days since 1970-01-01 (H. Hinnant's public-domain algorithm).
+/// (year, month 1–12, day 1–31) from days since 1970-01-01.
+///
+/// `NaiveDate` only covers about ±262143 years, but the system clock can run past it, so this
+/// stays exact for every `days`. The Gregorian calendar repeats every 400 years (146_097 days):
+/// chrono converts the day within its cycle and whole cycles go back into the year (an i128 so no
+/// `days` overflows), which matches the proleptic Gregorian answer the old hand-rolled code gave.
+/// 1970-01-01 is `num_days_from_ce` 719_163.
fn civil(days: i64) -> (i64, u32, u32) {
- let z = days + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z.rem_euclid(146_097);
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
- let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
- (yoe + era * 400 + i64::from(m <= 2), m, d)
+ use chrono::Datelike;
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
+ Some(d) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
+ }
+ // Unreachable: `rest + 719_163` is always a valid CE day.
+ None => (1970, 1, 1),
+ }
}
-/// Days since 1970-01-01 of a civil date (inverse of [`civil`]).
+/// Days since 1970-01-01 of a civil date (inverse of [`civil`]), exact for any year.
+///
+/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year within
+/// its cycle and whole cycles are added back as days (an i128 so the extremes can't overflow),
+/// which matches the proleptic Gregorian count the old hand-rolled code gave. 1970-01-01 is
+/// `num_days_from_ce` 719_163.
+///
+/// A day past the end of its month carries into the next one, as before chrono (`2019-02-30`
+/// is 2019-03-02: `parse_iso_date` accepts any day 1..=31): chrono places the 1st of the month
+/// and the rest of the days are added on. Only a month outside 1..=12 reads as 0.
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
- let y = if m <= 2 { y - 1 } else { y };
- let era = y.div_euclid(400);
- let yoe = y.rem_euclid(400);
- let mp = i64::from(if m > 2 { m - 3 } else { m + 9 });
- let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
- era * 146_097 + doe - 719_468
+ let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400));
+ i32::try_from(year)
+ .ok()
+ .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, 1))
+ .and_then(|first| {
+ let days = i128::from(chrono::Datelike::num_days_from_ce(&first)) - 719_163 + cycles * 146_097 + i128::from(d) - 1;
+ i64::try_from(days).ok()
+ })
+ .unwrap_or(0)
}
/// Unix seconds of an ISO 8601 timestamp such as `2019-12-16T09:48:02+01:00` (seconds, the
@@ -294,6 +312,30 @@ mod tests {
assert_eq!(format_date(951_782_400, "yyyy-MM-dd"), "2000-02-29");
}
+ #[test]
+ fn dates_beyond_chrono_range() {
+ // Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; the clock can reach this,
+ // so keep the proleptic Gregorian date the old formula gave rather than the epoch, on both
+ // sides of the epoch. Values from the pre-chrono formula, through the public `format_date`.
+ assert_eq!(format_date(10_000_000_000_000, "yyyy-MM-dd HH:mm"), "318857-05-20 17:46");
+ assert_eq!(format_date(-8_334_659_289_600, "yyyy-MM-dd"), "-262145-03-01");
+ // `days_from_civil` is exact and inverts `civil` past the range, both sides.
+ for (y, m, d, days) in
+ [(262_144i64, 1u32, 1u32, 95_026_602i64), (300_000, 6, 15, 108_853_388), (-262_145, 3, 1, -96_465_964), (-300_000, 2, 29, -110_292_219)]
+ {
+ assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}");
+ assert_eq!(civil(days), (y, m, d), "{days}");
+ }
+ // The extremes must not overflow or panic (i128 intermediates).
+ let _ = (civil(i64::MAX), civil(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1));
+ // A day past the end of its month carries over, as before chrono, instead of reading as 1970.
+ assert_eq!(days_from_civil(2019, 2, 30), days_from_civil(2019, 3, 2));
+ assert_eq!(days_from_civil(2024, 2, 30), days_from_civil(2024, 3, 1));
+ assert_eq!(days_from_civil(2019, 4, 31), days_from_civil(2019, 5, 1));
+ assert_eq!(parse_iso_date("2019-02-30"), Some(1_551_484_800));
+ assert_eq!(parse_iso_date("2019-02-30T09:48:02Z"), parse_iso_date("2019-03-02T09:48:02Z"));
+ }
+
#[test]
fn iso_dates() {
assert_eq!(parse_iso_date("2026-10-01T14:05:00Z"), Some(1_790_863_500));
diff --git a/crates/epub/Cargo.toml b/crates/epub/Cargo.toml
index 0cbe1673c..2836b0a92 100644
--- a/crates/epub/Cargo.toml
+++ b/crates/epub/Cargo.toml
@@ -12,3 +12,4 @@ workspace = true
designcraft-doc = { workspace = true }
designcraft-color = { workspace = true }
zip = { workspace = true }
+chrono = { workspace = true }
diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs
index 84bf56f1e..bd4571501 100644
--- a/crates/epub/src/lib.rs
+++ b/crates/epub/src/lib.rs
@@ -123,19 +123,32 @@ fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) {
}
/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`.
+/// A time outside chrono's year range still converts: the calendar repeats every 400 years, so the
+/// instant is moved into range by whole cycles and only the year is carried (matches the old
+/// `civil_from_days`), rather than resetting to the epoch.
fn iso8601(t: i64) -> String {
- let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400));
- // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
- let z = days + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z - era * 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let day = doy - (153 * mp + 2) / 5 + 1;
- let month = if mp < 10 { mp + 3 } else { mp - 9 };
- let year = yoe + era * 400 + i64::from(month <= 2);
- format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60)
+ let (y, dt) = utc_parts(t, 0);
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ"))
+}
+
+/// Unix seconds (+ nanos) → (proleptic Gregorian year, the instant moved into chrono's range by
+/// whole 400-year cycles). Month, day and time of day come from chrono; only the year is carried,
+/// which keeps every `i64` exact. chrono is the only calendar logic.
+fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
/// The package's `dcterms:modified`: the given time, else the document's last save or creation.
@@ -1582,6 +1595,14 @@ mod tests {
(951_782_400, "2000-02-29T00:00:00Z"),
(1_791_891_000, "2026-10-13T11:30:00Z"),
(-86_400, "1969-12-31T00:00:00Z"),
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ (253_402_300_800, "10000-01-01T00:00:00Z"),
+ // Beyond chrono's range the old `civil_from_days` still converted; pin that output
+ // (never the epoch) in both directions and at the `i64` extremes.
+ (10_000_000_000_000, "318857-05-20T17:46:40Z"),
+ (-10_000_000_000_000, "-314918-08-13T06:13:20Z"),
+ (i64::MAX, "292277026596-12-04T15:30:07Z"),
+ (i64::MIN, "-292277022657-01-27T08:29:52Z"),
] {
assert_eq!(iso8601(t), want);
}
diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml
index 6f40462b7..09da578ee 100644
--- a/crates/pdf/Cargo.toml
+++ b/crates/pdf/Cargo.toml
@@ -20,6 +20,7 @@ designcraft-images = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
# The placed-PDF version check names hayro's PdfVersion (krilla::pdf::Pdf::version returns it).
hayro-syntax = "0.7.2"
diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs
index 4e7627e9e..df44acbf2 100644
--- a/crates/pdf/src/export.rs
+++ b/crates/pdf/src/export.rs
@@ -436,20 +436,25 @@ fn date_time(t: i64) -> krilla::metadata::DateTime {
.utc_offset_minute(0)
}
-/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian.
+/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian. A time
+/// outside chrono's year range still converts (the calendar repeats every 400 years, so the
+/// instant is moved into range by whole cycles and only the year is carried, matching the old
+/// `civil`), rather than resetting to the epoch. `date_time` applies the PDF `0..=9999` year clamp.
pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
- let days = t.div_euclid(86_400);
- let secs = t.rem_euclid(86_400);
- let z = days + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z - era * 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let day = doy - (153 * mp + 2) / 5 + 1;
- let month = if mp < 10 { mp + 3 } else { mp - 9 };
- let year = yoe + era * 400 + i64::from(month <= 2);
- (year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8)
+ use chrono::{Datelike, Timelike};
+ let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
+ (y, d.month() as u8, d.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8)
+ }
+ _ => (1970, 1, 1, 0, 0, 0), // unreachable
+ }
}
/// A successful conversion and its name-independent diagnostic. Replayed once for each asset.
@@ -2117,4 +2122,19 @@ mod eps_tests {
assert_eq!(images, vec![("DeviceCMYK".to_string(), pixels.clone())], "{standard:?}");
}
}
+
+ #[test]
+ fn civil_dates_match_the_old_cycle_conversion_including_beyond_chrono() {
+ use super::civil;
+ assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
+ assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0));
+ assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
+ // Beyond chrono's year range the old `civil` still converted; pin that output, never the
+ // epoch. `date_time` is what clamps the year to the PDF `0..=9999` range afterwards.
+ assert_eq!(civil(253_402_300_800), (10000, 1, 1, 0, 0, 0));
+ assert_eq!(civil(10_000_000_000_000), (318857, 5, 20, 17, 46, 40));
+ assert_eq!(civil(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20));
+ assert_eq!(civil(i64::MAX), (292277026596, 12, 4, 15, 30, 7));
+ assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
+ }
}
diff --git a/crates/pptx/Cargo.toml b/crates/pptx/Cargo.toml
index c6e1c876f..afc90c69c 100644
--- a/crates/pptx/Cargo.toml
+++ b/crates/pptx/Cargo.toml
@@ -19,6 +19,7 @@ skrifa = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
zip = { workspace = true }
+chrono = { workspace = true }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs
index ed5ad553b..13939aecc 100644
--- a/crates/pptx/src/package.rs
+++ b/crates/pptx/src/package.rs
@@ -290,20 +290,25 @@ fn core(p: &Package) -> String {
s
}
-/// Unix seconds → UTC civil date and time (proleptic Gregorian).
+/// Unix seconds → UTC civil date and time (proleptic Gregorian), year clamped to the W3CDTF
+/// `0..=9999` range. A time outside chrono's year range still converts (the calendar repeats every
+/// 400 years, so the instant is moved into range by whole cycles and only the year is carried,
+/// matching the old `civil`), then the year is clamped — never a silent reset to the epoch.
fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) {
- let days = t.div_euclid(86_400);
- let secs = t.rem_euclid(86_400);
- let z = days + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z.rem_euclid(146_097);
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
- let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
- let y = yoe + era * 400 + i64::from(m <= 2);
- (y.clamp(0, 9999), m, d, (secs / 3600) as u32, (secs / 60 % 60) as u32, (secs % 60) as u32)
+ use chrono::{Datelike, Timelike};
+ let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
+ (y.clamp(0, 9999), d.month(), d.day(), t.hour(), t.minute(), t.second())
+ }
+ _ => (1970, 1, 1, 0, 0, 0), // unreachable
+ }
}
/// A neutral theme: text sets its own fonts and colours, so these only matter for new objects.
@@ -331,5 +336,13 @@ mod tests {
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
+ // Beyond chrono's year range the old `civil` still converted and clamped only the year to
+ // the W3CDTF `0..=9999` range; pin that (month/day/time keep the real instant), never the
+ // epoch, in both directions and at the `i64` extremes.
+ assert_eq!(civil(253_402_300_800), (9999, 1, 1, 0, 0, 0));
+ assert_eq!(civil(10_000_000_000_000), (9999, 5, 20, 17, 46, 40));
+ assert_eq!(civil(-10_000_000_000_000), (0, 8, 13, 6, 13, 20));
+ assert_eq!(civil(i64::MAX), (9999, 12, 4, 15, 30, 7));
+ assert_eq!(civil(i64::MIN), (0, 1, 27, 8, 29, 52));
}
}
From 2aa5da5950580f077cadb4d6c1a0e29e1e173c48 Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Wed, 21 Oct 2026 20:40:26 -0400
Subject: [PATCH 18/43] Paragraph rules: Rule Above's offset is measured from
the baseline (#1535)
* Paragraph rules: Rule Above's offset is measured from the first line's baseline
* ROADMAP: Rule Above offset progress line
---
ROADMAP.md | 1 +
crates/compose/src/lib.rs | 5 +++--
crates/compose/src/tests.rs | 20 ++++++++++++++++++++
3 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index a7b5105d3..5d91d43f9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 4a2c66397..89ea89bd3 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1860,11 +1860,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above), tail: None };
}
// Rules and shading for the paragraph.
- if let Some((fi, bl, asc, _)) = first_line_rect {
+ if let Some((fi, bl, _, _)) = first_line_rect {
let ft = &mut out.frames[fi];
if pp.rule_above.on {
let r = &pp.rule_above;
- let y = bl - asc - r.offset;
+ // The offset runs from the first line's baseline up to the rule's bottom edge.
+ let y = bl - r.offset;
let col = ft
.lines
.iter()
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index ed4c7c476..15c412ada 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -7290,3 +7290,23 @@ fn greek_text_gets_greek_hyphenation() {
let el = hyphen::Lang::for_language("Greek").unwrap();
assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]);
}
+
+/// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in
+/// InDesign: a rule set clear of the cap height sits between the rows, not across the row above
+/// (an INDD project list whose rules crossed the previous paragraph's text).
+#[test]
+fn rule_above_offset_is_measured_from_the_baseline() {
+ use designcraft_doc::Rule;
+ let rule = Rule { on: true, weight: 1.5, offset: 10.0, ..Default::default() };
+ let para = ParaAttrs { rule_above: Some(rule), ..Default::default() };
+ let (d, sid, fid) = doc_with("Row one\nRow two", Rect::new(0.0, 0.0, 300.0, 200.0), para);
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ let lines = all_lines(&cs);
+ let (first, second) = (lines[0], lines[1]);
+ let mut decos: Vec = cs.frame(fid).unwrap().decos.iter().map(|dc| dc.rect).collect();
+ decos.sort_by(|a, b| a.y0.total_cmp(&b.y0));
+ let rule = decos[1];
+ assert!((rule.y1 - (second.baseline - 10.0)).abs() < 1e-6, "{rule:?}, baseline {}", second.baseline);
+ assert!((rule.height() - 1.5).abs() < 1e-6);
+ assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline);
+}
From 438d7b6d79e74b3491f84eef5e295951a07b925c Mon Sep 17 00:00:00 2001
From: Zuujis <160198409+zuujiscom@users.noreply.github.com>
Date: Wed, 21 Oct 2026 20:46:38 -0500
Subject: [PATCH 19/43] Dedupe the 400-year-cycle UTC conversion and the EPUB
package writer (#1537)
* Dedupe the 400-year-cycle UTC conversion and the EPUB package writer
SonarCloud flagged new-code duplication on main (17%): five copies of the
"move the instant into chrono's range by whole 400-year cycles" conversion
and two copies of the EPUB OPF/container/zip writer.
- designcraft-doc gains a `time` module: `date_parts(days)` and
`utc_parts(secs, nanos)`. App logging, EPUB `iso8601`, PDF `civil`, PPTX
`civil` and text-variable `civil` are now thin callers; each keeps its own
format string and year clamp. The app reaches it through
`designcraft_engine::doc`, so its direct chrono dependency is dropped.
- EPUB: `package_opf` + `write_package` shared by fixed-layout and
reflowable export (same entry order and compression).
- Boundary tests on the helper: pre-1970, year 9999 -> 10000, i64 extremes,
nanos past a second.
Output is unchanged: existing date tests pass, and fixed/reflowable EPUBs
(with/without cover, author, pre-1970 and i64::MAX dates) are byte-identical
to origin/main.
* time: drop the unreachable i128 clamp and match arms in utc_parts
Whole 400-year cycles times 400 stay under 2.6e16 for any i64 day count,
so the year can't overflow i64 and the i128 + saturate was dead code
(carried over from the copies this replaced). The date and time lookups
can't fail either; each keeps one unwrap_or_default instead of a match
with a fake 1970 fallback. Output and tests unchanged.
---
Cargo.lock | 1 -
apps/designcraft/Cargo.toml | 1 -
apps/designcraft/src/logging.rs | 22 +----
crates/doc/src/lib.rs | 1 +
crates/doc/src/time.rs | 74 +++++++++++++++++
crates/doc/src/vars.rs | 20 +----
crates/epub/src/lib.rs | 137 +++++++++++++-------------------
crates/pdf/src/export.rs | 15 +---
crates/pptx/src/package.rs | 15 +---
9 files changed, 140 insertions(+), 146 deletions(-)
create mode 100644 crates/doc/src/time.rs
diff --git a/Cargo.lock b/Cargo.lock
index 1180cf0b1..0b022ecfe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -945,7 +945,6 @@ name = "designcraft"
version = "0.11.0"
dependencies = [
"arboard",
- "chrono",
"designcraft-engine",
"designcraft-ui-egui",
"eframe",
diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml
index 81a267aa9..7dc83bc55 100644
--- a/apps/designcraft/Cargo.toml
+++ b/apps/designcraft/Cargo.toml
@@ -18,7 +18,6 @@ egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
-chrono = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for designcraft.exe; build.rs only uses it when the target is Windows.
diff --git a/apps/designcraft/src/logging.rs b/apps/designcraft/src/logging.rs
index 5fe70a749..04238e853 100644
--- a/apps/designcraft/src/logging.rs
+++ b/apps/designcraft/src/logging.rs
@@ -97,31 +97,11 @@ pub fn timestamp(t: SystemTime) -> String {
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
- let (y, t) = utc_parts(secs, since.subsec_nanos());
+ let (y, t) = designcraft_engine::doc::time::utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
-/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
-/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
-/// so month, day and time of day come from chrono and only the year is carried, which keeps every
-/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
-fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
- use chrono::Datelike;
- let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
- let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
- // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
- let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
- let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
- match (date, time) {
- (Some(d), Some(t)) => {
- let y = i128::from(d.year()) + i128::from(cycles) * 400;
- (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
- }
- _ => (1970, chrono::NaiveDateTime::default()), // unreachable
- }
-}
-
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("designcraft.{n}.log")) }
}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 1bf834ae0..5c854ba9f 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -35,6 +35,7 @@ pub mod story;
pub mod styles;
mod swatch_refs;
pub mod table;
+pub mod time;
pub mod vars;
pub mod xref;
diff --git a/crates/doc/src/time.rs b/crates/doc/src/time.rs
new file mode 100644
index 000000000..fb21b4b4b
--- /dev/null
+++ b/crates/doc/src/time.rs
@@ -0,0 +1,74 @@
+//! Unix time → proleptic Gregorian calendar, exact for every `i64`.
+//!
+//! chrono is the only calendar logic. `NaiveDate` covers about ±262143 years, but clocks and files
+//! can hold instants past that, so these extend its range: the Gregorian calendar repeats every
+//! 400 years (146_097 days), so the instant is moved into chrono's range by whole cycles, chrono
+//! gives the month, day and time of day, and the cycles are added back to the year (at most about
+//! 2.5e16, so it fits an `i64`). Callers own only their output format and any year clamp.
+
+use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime};
+
+/// Days since 1970-01-01 → (proleptic Gregorian year, a chrono date with the same month and day;
+/// its own year is not the real one).
+pub fn date_parts(days: i64) -> (i64, NaiveDate) {
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // 1970-01-01 is CE day 719_163; one cycle on from it is well inside chrono's range.
+ let d = NaiveDate::from_num_days_from_ce_opt(rest as i32 + 719_163).unwrap_or_default();
+ (i64::from(d.year()) + cycles * 400, d)
+}
+
+/// Unix seconds (+ nanos, clamped to 999_999_999) → (proleptic Gregorian year, a chrono date and
+/// time with the same month, day and time of day; its own year is not the real one).
+pub fn utc_parts(secs: i64, nanos: u32) -> (i64, NaiveDateTime) {
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (y, d) = date_parts(days);
+ let t = NaiveTime::from_num_seconds_from_midnight_opt(sod as u32, nanos.min(999_999_999)).unwrap_or_default();
+ (y, d.and_time(t))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::Timelike;
+
+ fn ymdhms(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
+ let (y, t) = utc_parts(secs, 0);
+ (y, t.month(), t.day(), t.hour(), t.minute(), t.second())
+ }
+
+ #[test]
+ fn epoch_and_ordinary_dates() {
+ assert_eq!(ymdhms(0), (1970, 1, 1, 0, 0, 0));
+ assert_eq!(ymdhms(951_782_400), (2000, 2, 29, 0, 0, 0));
+ assert_eq!(ymdhms(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
+ }
+
+ #[test]
+ fn before_1970() {
+ assert_eq!(ymdhms(-1), (1969, 12, 31, 23, 59, 59));
+ assert_eq!(ymdhms(-86_400), (1969, 12, 31, 0, 0, 0));
+ assert_eq!(ymdhms(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20));
+ }
+
+ #[test]
+ fn year_9999_rolls_into_10000() {
+ assert_eq!(ymdhms(253_402_300_799), (9999, 12, 31, 23, 59, 59));
+ assert_eq!(ymdhms(253_402_300_800), (10000, 1, 1, 0, 0, 0));
+ }
+
+ #[test]
+ fn i64_extremes_stay_exact() {
+ assert_eq!(ymdhms(i64::MAX), (292277026596, 12, 4, 15, 30, 7));
+ assert_eq!(ymdhms(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
+ // Days (not seconds) at the extremes: whole cycles still fit, so the year can't overflow.
+ let ((hi, _), (lo, _)) = (date_parts(i64::MAX), date_parts(i64::MIN));
+ assert!(hi > 25_000_000_000_000_000 && hi < i64::MAX && lo < -25_000_000_000_000_000 && lo > i64::MIN);
+ }
+
+ #[test]
+ fn nanos_past_a_second_are_clamped() {
+ assert_eq!(utc_parts(0, 999_999_999).1.nanosecond(), 999_999_999);
+ assert_eq!(utc_parts(0, 1_500_000_000).1.nanosecond(), 999_999_999);
+ assert_eq!(utc_parts(0, u32::MAX).1.second(), 0);
+ }
+}
diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs
index 0a3f5ea46..c53eedb1a 100644
--- a/crates/doc/src/vars.rs
+++ b/crates/doc/src/vars.rs
@@ -126,24 +126,12 @@ pub fn now() -> i64 {
}
}
-/// (year, month 1–12, day 1–31) from days since 1970-01-01.
-///
-/// `NaiveDate` only covers about ±262143 years, but the system clock can run past it, so this
-/// stays exact for every `days`. The Gregorian calendar repeats every 400 years (146_097 days):
-/// chrono converts the day within its cycle and whole cycles go back into the year (an i128 so no
-/// `days` overflows), which matches the proleptic Gregorian answer the old hand-rolled code gave.
-/// 1970-01-01 is `num_days_from_ce` 719_163.
+/// (year, month 1–12, day 1–31) from days since 1970-01-01, exact for every `days`
+/// (see [`crate::time::date_parts`]).
fn civil(days: i64) -> (i64, u32, u32) {
use chrono::Datelike;
- let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
- match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
- Some(d) => {
- let y = i128::from(d.year()) + i128::from(cycles) * 400;
- (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
- }
- // Unreachable: `rest + 719_163` is always a valid CE day.
- None => (1970, 1, 1),
- }
+ let (y, d) = crate::time::date_parts(days);
+ (y, d.month(), d.day())
}
/// Days since 1970-01-01 of a civil date (inverse of [`civil`]), exact for any year.
diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs
index bd4571501..1df759608 100644
--- a/crates/epub/src/lib.rs
+++ b/crates/epub/src/lib.rs
@@ -127,30 +127,11 @@ fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) {
/// instant is moved into range by whole cycles and only the year is carried (matches the old
/// `civil_from_days`), rather than resetting to the epoch.
fn iso8601(t: i64) -> String {
- let (y, dt) = utc_parts(t, 0);
+ let (y, dt) = designcraft_doc::time::utc_parts(t, 0);
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ"))
}
-/// Unix seconds (+ nanos) → (proleptic Gregorian year, the instant moved into chrono's range by
-/// whole 400-year cycles). Month, day and time of day come from chrono; only the year is carried,
-/// which keeps every `i64` exact. chrono is the only calendar logic.
-fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
- use chrono::Datelike;
- let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
- let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
- // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
- let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
- let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
- match (date, time) {
- (Some(d), Some(t)) => {
- let y = i128::from(d.year()) + i128::from(cycles) * 400;
- (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
- }
- _ => (1970, chrono::NaiveDateTime::default()), // unreachable
- }
-}
-
/// The package's `dcterms:modified`: the given time, else the document's last save or creation.
fn modified(doc: &Document, opts: &EpubOptions) -> String {
let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created));
@@ -206,8 +187,6 @@ pub struct FixedPage {
/// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page.
pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result, EpubError> {
- let io = |e: std::io::Error| EpubError(e.to_string());
- let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let lang = language_tag(&opts.language);
let id = identifier(doc, opts, &title);
@@ -239,12 +218,41 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
"\n\n\n{}\n\n\n",
xml_text(&title)
);
- let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default();
- let opf = format!(
- "\n\n\n{}\n{}\n{lang}{author}\n{modified}\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\n\n",
- xml_text(&id),
- xml_text(&title)
+ let rendition = format!(
+ "\npre-paginated\nauto\n"
);
+ let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified };
+ let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine);
+ let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png")));
+ write_package(&opf, &nav, entries)
+}
+
+/// The package metadata both EPUB flavours share.
+struct PackageHead<'a> {
+ lang: &'a str,
+ id: &'a str,
+ title: &'a str,
+ author: Option<&'a str>,
+ modified: &'a str,
+}
+
+/// The OPF package document. `prefix` goes on `` (with its leading space), `meta` after
+/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines.
+fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String {
+ let PackageHead { lang, id, title, author, modified } = h;
+ let author = author.map(|a| format!("{}", xml_text(a))).unwrap_or_default();
+ format!(
+ "\n\n\n{}\n{}\n{lang}{author}\n{modified}{meta}\n\n\n\n{manifest}\n\n{spine}\n\n",
+ xml_text(id),
+ xml_text(title)
+ )
+}
+
+/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order
+/// as (path, bytes, stored uncompressed).
+fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator) -> Result, EpubError> {
+ let io = |e: std::io::Error| EpubError(e.to_string());
+ let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let container = "\n\n";
let mut buf = std::io::Cursor::new(Vec::new());
{
@@ -259,9 +267,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
z.start_file(name, deflate).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
- for (name, data) in &files {
- let o = if name.ends_with(".png") { stored } else { deflate };
- z.start_file(name.as_str(), o).map_err(zerr)?;
+ for (name, data, store) in entries {
+ z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
z.finish().map_err(zerr)?;
@@ -1379,8 +1386,6 @@ fn base64(data: &[u8]) -> String {
/// Export the document as a reflowable EPUB 3.
pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> {
- let io = |e: std::io::Error| EpubError(e.to_string());
- let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let id = identifier(doc, opts, &title);
let modified = modified(doc, opts);
@@ -1441,56 +1446,26 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr
);
}
let cover_spine = if opts.cover.is_some() { "\n" } else { "" };
- let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default();
- let opf = format!(
- "\n\n\n{}\n{}\n{lang}{author}\n{modified}\n\n\n\n{manifest}\n\n{cover_spine}{spine}\n\n",
- xml_text(&id),
- xml_text(&title)
- );
- let container = "\n\n";
- let mut buf = std::io::Cursor::new(Vec::new());
- {
- let mut z = zip::ZipWriter::new(&mut buf);
- let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
- let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
- z.start_file("mimetype", stored).map_err(zerr)?;
- z.write_all(b"application/epub+zip").map_err(io)?;
- for (name, data) in [
- ("META-INF/container.xml", container.as_bytes()),
- ("OEBPS/content.opf", opf.as_bytes()),
- ("OEBPS/nav.xhtml", nav.as_bytes()),
- ("OEBPS/style.css", stylesheet_with(doc, &opts.generic_families, &opts.fonts).as_bytes()),
- ] {
- z.start_file(name, deflate).map_err(zerr)?;
- z.write_all(data).map_err(io)?;
- }
- for (k, c) in chapters.iter().enumerate() {
- z.start_file(format!("OEBPS/{}", chapter_file(k)), deflate).map_err(zerr)?;
- z.write_all(c.as_bytes()).map_err(io)?;
- }
- for (name, (_, data)) in &images {
- z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?;
- z.write_all(data).map_err(io)?;
- }
- for (k, f) in opts.fonts.iter().enumerate() {
- z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?;
- z.write_all(&f.data).map_err(io)?;
- }
- if let Some(c) = &opts.cover {
- z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?;
- z.write_all(c).map_err(io)?;
- let xhtml = format!(
- "\n\n\n{}\n\n\n",
- xml_text(&title),
- cover_file(c).0,
- xml_attr(&title)
- );
- z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?;
- z.write_all(xhtml.as_bytes()).map_err(io)?;
- }
- z.finish().map_err(zerr)?;
+ let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified };
+ let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}"));
+ let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts);
+ let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)];
+ entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false)));
+ entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true)));
+ entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false)));
+ let cover_xhtml = opts.cover.as_ref().map(|c| {
+ format!(
+ "\n\n\n{}\n\n\n",
+ xml_text(&title),
+ cover_file(c).0,
+ xml_attr(&title)
+ )
+ });
+ if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) {
+ entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true));
+ entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false));
}
- Ok(buf.into_inner())
+ write_package(&opf, &nav, entries)
}
#[cfg(test)]
diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs
index df44acbf2..3abcdb521 100644
--- a/crates/pdf/src/export.rs
+++ b/crates/pdf/src/export.rs
@@ -442,19 +442,8 @@ fn date_time(t: i64) -> krilla::metadata::DateTime {
/// `civil`), rather than resetting to the epoch. `date_time` applies the PDF `0..=9999` year clamp.
pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
use chrono::{Datelike, Timelike};
- let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
- let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
- // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
- let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
- let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
- match (date, time) {
- (Some(d), Some(t)) => {
- let y = i128::from(d.year()) + i128::from(cycles) * 400;
- let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
- (y, d.month() as u8, d.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8)
- }
- _ => (1970, 1, 1, 0, 0, 0), // unreachable
- }
+ let (y, t) = designcraft_doc::time::utc_parts(t, 0);
+ (y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8)
}
/// A successful conversion and its name-independent diagnostic. Replayed once for each asset.
diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs
index 13939aecc..7e9e123e0 100644
--- a/crates/pptx/src/package.rs
+++ b/crates/pptx/src/package.rs
@@ -296,19 +296,8 @@ fn core(p: &Package) -> String {
/// matching the old `civil`), then the year is clamped — never a silent reset to the epoch.
fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) {
use chrono::{Datelike, Timelike};
- let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
- let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
- // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
- let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
- let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
- match (date, time) {
- (Some(d), Some(t)) => {
- let y = i128::from(d.year()) + i128::from(cycles) * 400;
- let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
- (y.clamp(0, 9999), d.month(), d.day(), t.hour(), t.minute(), t.second())
- }
- _ => (1970, 1, 1, 0, 0, 0), // unreachable
- }
+ let (y, t) = designcraft_doc::time::utc_parts(t, 0);
+ (y.clamp(0, 9999), t.month(), t.day(), t.hour(), t.minute(), t.second())
}
/// A neutral theme: text sets its own fonts and colours, so these only matter for new objects.
From 496b1899a2ab397e8057e7e482f7c21c248dc795 Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 01:18:41 -0400
Subject: [PATCH 20/43] macOS menu bar: menus keep working after a language
change; key equivalents follow the shortcuts (#1540)
* macOS menu bar: keep working after a rebuild; key equivalents follow the shortcuts in effect
* macOS menu bar: the shortcut test sets its key with window.setShortcut
---
ROADMAP.md | 1 +
apps/designcraft/src/native_menu.rs | 52 ++++++++++++++++++++---------
crates/ui-egui/src/menus.rs | 20 +++++++++--
3 files changed, 55 insertions(+), 18 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 5d91d43f9..013cc48db 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs
index 86a88e5c0..e444641bf 100644
--- a/apps/designcraft/src/native_menu.rs
+++ b/apps/designcraft/src/native_menu.rs
@@ -27,6 +27,9 @@ pub struct NativeMenu {
shown_for: (String, bool),
/// File › Open Recent, rebuilt when the list of recent files changes.
recent: Option,
+ /// The commands with key equivalents and the shortcut each was given: the menu is rebuilt
+ /// when one changes, so a shortcut moved to another command leaves its old item.
+ keys: Vec<(String, Option)>,
}
struct RecentMenu {
@@ -50,21 +53,28 @@ fn menu_title(app: &DesignApp, label: &str) -> String {
menus::native_label(designcraft_ui_egui::i18n::tr(&app.ui.language, label))
}
-/// A command's default shortcut, as the in-window menus show it.
-fn shortcut(id: &str) -> Option<&'static str> {
- menus::ui_label(id).and_then(|(_, sc)| sc).or_else(|| designcraft_engine::find_command(id).and_then(|c| c.shortcut))
+/// A command's shortcut in effect (the user's, else the default), as the in-window menus show it.
+fn shortcut(app: &DesignApp, id: &str) -> Option {
+ menus::shortcut_of(app, id)
}
impl NativeMenu {
pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self {
// A click in the menu bar reaches the app with no input over the window, so nothing would
// draw the frame that runs the command until the pointer or a key comes back: wake egui.
+ // muda keeps the first handler for the life of the process, so this channel serves every
+ // menu bar built later (`build_bar`).
let (tx, events) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
let _ = tx.send(e);
repaint.request_repaint();
}));
+ Self::build_bar(app, events)
+ }
+
+ /// The menu bar for the current language and settings, fed by `events`.
+ fn build_bar(app: &mut DesignApp, events: Receiver) -> Self {
let menu = Menu::new();
let mut items = HashMap::new();
let mut counter = 0usize;
@@ -75,7 +85,7 @@ impl NativeMenu {
for entry in menus::MAC_APP_MENU {
let _ = match *entry {
AppMenuItem::Cmd { id, label, command } => {
- let sc = shortcut(command).and_then(accel);
+ let sc = shortcut(app, command).as_deref().and_then(accel);
let item = MenuItem::with_id(id, menu_title(app, label), true, sc);
let appended = app_menu.append(&item);
items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item)));
@@ -96,15 +106,19 @@ impl NativeMenu {
}
menu.init_for_nsapp();
app.native_menu = true;
- app.native_shortcuts = items
- .values()
- .filter_map(|(id, p, _)| {
- let sc = shortcut(id)?;
- (p.is_null() && accel(sc).is_some()).then(|| id.clone())
- })
- .collect();
+ let keys: Vec<(String, Option)> =
+ items.values().filter(|(_, p, _)| p.is_null()).map(|(id, _, _)| (id.clone(), shortcut(app, id))).collect();
+ app.native_shortcuts = keys.iter().filter(|(_, sc)| sc.as_deref().and_then(accel).is_some()).map(|(id, _)| id.clone()).collect();
let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None });
- Self { _menu: menu, items, events, last_refresh: 0.0, shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), recent }
+ Self {
+ _menu: menu,
+ items,
+ events,
+ last_refresh: 0.0,
+ shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)),
+ recent,
+ keys,
+ }
}
/// Put the current recent files at the top of File › Open Recent when they changed.
@@ -153,13 +167,21 @@ impl NativeMenu {
// back by Cancel): the system menu bar follows at once, like the panels (the new menu
// replaces the old one as the application's menu before that one is dropped).
if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 {
- *self = Self::install(app, ctx);
+ let events = std::mem::replace(&mut self.events, channel().1);
+ *self = Self::build_bar(app, events);
}
let now = designcraft_ui_egui::now_ms();
if now - self.last_refresh < 250.0 {
return;
}
self.last_refresh = now;
+ // A shortcut set in the Keyboard Shortcuts dialog: macOS gives key equivalents to the
+ // menu bar before the window sees the key, so the bar must carry the new keys.
+ if self.keys.iter().any(|(id, sc)| shortcut(app, id) != *sc) {
+ let events = std::mem::replace(&mut self.events, channel().1);
+ *self = Self::build_bar(app, events);
+ return;
+ }
self.refresh_recent(app);
for (cmd, params, h) in self.items.values() {
let en = menus::native_menu_enabled(app, ctx, cmd);
@@ -199,10 +221,10 @@ fn build(
}
// Hidden CJK commands; `NativeMenu::poll` rebuilds the bar when Show CJK Features changes.
Item::Cmd { id, .. } if !menus::shown(app, id) => {}
- Item::Cmd { label, id, params, shortcut } => {
+ Item::Cmd { label, id, params, .. } => {
*counter += 1;
let mid = format!("dc{counter}");
- let sc = if params.is_null() { shortcut.and_then(accel) } else { None };
+ let sc = if params.is_null() { shortcut(app, id).as_deref().and_then(accel) } else { None };
let label = menu_title(app, label);
let handle = if menus::checked(app, id, params).is_some() {
let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc);
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 79b8b2c4e..5677c329e 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -3081,9 +3081,10 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
}
}
if let Some((id, exact)) = fired {
- // The native menu's accelerator fires only for the shortcut's own key and modifiers; the
- // Plus key standing in for = never reaches it.
- if exact && app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) {
+ // The native menu bar carries the shortcuts in effect, the user's included, and its
+ // accelerator fires only for the shortcut's own key and modifiers; the Plus key standing
+ // in for = never reaches it.
+ if exact && app.native_shortcuts.contains(&id) {
continue; // The native menu handles it.
}
// The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's
@@ -4506,6 +4507,19 @@ mod tests {
let z = app.view().unwrap().zoom;
assert_eq!(z > z0, zooms_in, "native menu, {k:?} {m:?}: {z0} -> {z}");
}
+ // A shortcut the user set runs here until the native menu bar carries it as its key
+ // equivalent; then only the bar runs it, once.
+ run_ui(&mut app, "window.setShortcut", &json!({"id": "view.zoomOut", "shortcut": "Cmd+9"})).unwrap().unwrap();
+ for (native, zooms_out) in [(false, true), (true, false)] {
+ if native {
+ app.native_shortcuts.insert("view.zoomOut".to_string());
+ }
+ let z0 = app.view().unwrap().zoom;
+ frame(&mut app, key(egui::Key::Num9, egui::Modifiers::COMMAND));
+ frame(&mut app, vec![]);
+ let z = app.view().unwrap().zoom;
+ assert_eq!(z < z0, zooms_out, "native {native}: {z0} -> {z}");
+ }
}
#[test]
From 8a6f9725b03c2ffd255cc582841e7f12f4d1a5ab Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 01:25:12 -0400
Subject: [PATCH 21/43] IDML: test a ruler guide in a column gutter without
facing pages (#1096) (#1170)
---
crates/idml/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 53 insertions(+)
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index 035333da4..81cebff70 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -4194,6 +4194,59 @@ mod liquid_page_rules {
}
}
+/// #1096: a ruler guide inside the gutter of a two-column page, written the way InDesign writes
+/// it (a `` child after its `MarginPreference`, with `Properties`/`GuideColor`, `PageIndex`
+/// and `GuideZone`), in a document without facing pages. A spread guide sits beside it.
+#[test]
+fn ruler_guide_in_a_column_gutter_imports_without_facing_pages() {
+ use designcraft_doc::Orientation::{Horizontal, Vertical};
+ let package = |view: &str| {
+ let prefs =
+ PREFS.replace(r#"FacingPages="true""#, r#"FacingPages="false""#).replace("", &format!("{view}"));
+ let spread = SPREAD.replace(
+ r#"
+
+ "#,
+ r#"
+ UseMasterColor
+
+
+
+ Cyan
+
+
+ Cyan
+
+ "#,
+ );
+ assert_ne!(spread, SPREAD, "the fixture's first page was replaced");
+ import_idml(&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),
+ ]))
+ .unwrap()
+ };
+ let guides =
+ |d: &Document| d.spreads[0].pages[0].guides.iter().map(|g| (g.orientation, g.position, g.spread, g.layer.is_some())).collect::>();
+ // The first page starts at x 0 in our spread coordinates, so the spread's and the page's ruler
+ // origins agree; no ViewPreference means InDesign's default, the spread.
+ for view in ["", r#""#, r#""#] {
+ let first = package(view);
+ assert!(!first.settings.facing_pages);
+ assert_eq!(guides(&first), [(Vertical, 180.0, false, true), (Horizontal, 300.0, true, true)], "{view:?}");
+ // The guide sits between the page's left margin (20) and right margin (470).
+ let m = first.spreads[0].pages[0].margin_rect();
+ assert!(m.x0 < 180.0 && 180.0 < m.x1);
+ let back = import_idml(&export_idml(&first)).unwrap();
+ assert_eq!(guides(&back), guides(&first), "round trip, {view:?}");
+ }
+}
+
/// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body
/// region (cells with `[None]` take it from there) and overridden on one cell.
const FIRST_BASELINE_STYLES: &str = r#"
From 2660395db553f182d23dadec8f4885c6498a0014 Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 01:31:36 -0400
Subject: [PATCH 22/43] macOS: a Language submenu in the app menu (#1541)
---
ROADMAP.md | 1 +
apps/designcraft/src/native_menu.rs | 12 ++++++++++++
crates/ui-egui/src/i18n.rs | 2 +-
crates/ui-egui/src/menus.rs | 11 ++++++++++-
4 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 013cc48db..9c4cb465c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs
index e444641bf..1ac9437d1 100644
--- a/apps/designcraft/src/native_menu.rs
+++ b/apps/designcraft/src/native_menu.rs
@@ -91,6 +91,18 @@ impl NativeMenu {
items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item)));
appended
}
+ AppMenuItem::Language => {
+ let sub = Submenu::new(menu_title(app, "Language"), true);
+ for (code, name) in designcraft_ui_egui::i18n::LANGUAGES {
+ let id = format!("dc-language-{}", if code.is_empty() { "en" } else { code });
+ let params = serde_json::json!({ "lang": code });
+ let checked = menus::checked(app, "app.language", ¶ms).unwrap_or(false);
+ let item = CheckMenuItem::with_id(id.clone(), menus::native_label(name), true, checked, None);
+ let _ = sub.append(&item);
+ items.insert(id, ("app.language".into(), params, Handle::Check(item)));
+ }
+ app_menu.append(&sub)
+ }
AppMenuItem::Sep => app_menu.append(&PredefinedMenuItem::separator()),
AppMenuItem::Services => app_menu.append(&PredefinedMenuItem::services(None)),
AppMenuItem::Hide => app_menu.append(&PredefinedMenuItem::hide(None)),
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index efe089ef9..c2abdb9a9 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -3155,7 +3155,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Graphic…", ["Graphic…", "Graphique…", "Graphic…", "Graphic…", "图形…"]),
("Home", ["Home", "Accueil", "Home", "Home", "主页"]),
("Join:", ["Join:", "Sommet :", "Join:", "Join:", "连接:"]),
- ("Language", ["Language", "Langue", "Language", "Language", "语言"]),
+ ("Language", ["Sprache", "Langue", "Idioma", "言語", "语言"]),
("Last Spread", ["Last Spread", "Dernière planche", "Last Spread", "Last Spread", "最后一个跨页"]),
("Load DTD…", ["Load DTD…", "Charger la DTD…", "Load DTD…", "Load DTD…", "载入 DTD…"]),
("Location:", ["Location:", "Emplacement :", "Location:", "Location:", "位置:"]),
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 5677c329e..c455b718d 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -2286,6 +2286,9 @@ pub enum AppMenuItem {
command: &'static str,
},
Sep,
+ /// Interface language: a submenu with each UI language in its own name, the current one
+ /// checked (`app.language {lang}`).
+ Language,
Services,
Hide,
HideOthers,
@@ -2298,6 +2301,7 @@ pub const MAC_APP_MENU: &[AppMenuItem] = &[
AppMenuItem::Cmd { id: "dc-discord", label: "Join the ArtCraft Discord…", command: "help.discord" },
AppMenuItem::Sep,
AppMenuItem::Cmd { id: "dc-settings", label: "Settings…", command: "app.preferences" },
+ AppMenuItem::Language,
AppMenuItem::Sep,
AppMenuItem::Services,
AppMenuItem::Sep,
@@ -5566,7 +5570,12 @@ mod tests {
let (Some(about), Some(settings)) = (pos("help.about"), pos("app.preferences")) else { panic!("About and Settings are in the app menu") };
assert!(about < settings);
assert_eq!(MAC_APP_MENU.get(settings - 1), Some(&AppMenuItem::Sep));
- assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Sep));
+ // Language follows Settings, as in the suite's other apps, titled in the interface language.
+ assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Language));
+ assert_eq!(MAC_APP_MENU.get(settings + 2), Some(&AppMenuItem::Sep));
+ for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
+ assert_ne!(crate::i18n::tr(code, "Language"), "Language", "{code}");
+ }
let label = |command: &str| {
MAC_APP_MENU.iter().find_map(|m| match m {
AppMenuItem::Cmd { label, command: c, .. } if *c == command => Some(*label),
From 3be2736d0a05ad329c0a2494067d8c7f32181e50 Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 01:47:49 -0400
Subject: [PATCH 23/43] Guides: select ruler guides and set their position
(#1282) (#1445)
* Guides: a dragged guide shows where it is (#1282)
While a guide is dragged out of a ruler or moved, a label beside the
pointer reads X: or Y: and the guide's place, in the ruler units and
measured the way the rulers measure (from the zero point, and from the
page origin when rulers restart per page).
* Guides: keep the drag readout inside the canvas
Near the canvas's right or bottom edge the readout flips to the other
side of the pointer, and it is kept inside the canvas in every case
(readout_at).
* Guides: the drag readout measures the spread under the pointer
The readout measured from the spread in the middle of the view (where the
rulers measure), but a guide is dropped on the spread under the pointer,
so with spreads above one another the number shown could differ from the
guide's place. It now measures that spread (ruler_pieces_of).
* Guides: no readout for an existing guide over another spread
An existing guide moves only on its own spread (guide_drag), so dropped
over another it stays where it was: the readout no longer shows a place
there it won't go.
* #1282: guide selection commands (guide.select, guide.selectAll, guide.deselect, guide.moveBy)
* #1282: select ruler guides on the canvas, X/Y in the Control bar and Properties
* #1282: guides take clicks over objects, a new guide is selected, group drag preview
* ROADMAP: the progress line lands in one commit after the guide PRs
---------
Co-authored-by: SBaksa
---
crates/doc/src/selection.rs | 14 +
crates/engine/src/cmd/edit.rs | 53 +-
crates/engine/src/cmd/guides.rs | 537 +++++++++++++++++++-
crates/engine/src/cmd/mod.rs | 2 +-
crates/engine/src/lib.rs | 11 +
crates/engine/src/tests.rs | 3 +-
crates/tools/src/lib.rs | 5 +
crates/tools/src/select.rs | 61 ++-
crates/ui-egui/src/canvas.rs | 315 +++++++++++-
crates/ui-egui/src/guide_selection_tests.rs | 258 ++++++++++
crates/ui-egui/src/i18n.rs | 5 +
crates/ui-egui/src/i18n/ar.rs | 2 +
crates/ui-egui/src/i18n/cs.rs | 2 +
crates/ui-egui/src/i18n/de.rs | 2 +
crates/ui-egui/src/i18n/it.rs | 2 +
crates/ui-egui/src/i18n/ja.rs | 2 +
crates/ui-egui/src/i18n/ka.rs | 2 +
crates/ui-egui/src/i18n/pl.rs | 2 +
crates/ui-egui/src/i18n/pt_br.rs | 2 +
crates/ui-egui/src/i18n/ru.rs | 2 +
crates/ui-egui/src/i18n/uk.rs | 2 +
crates/ui-egui/src/lib.rs | 1 +
crates/ui-egui/src/menus.rs | 39 +-
crates/ui-egui/src/object_control.rs | 15 +-
crates/ui-egui/src/panels/mod.rs | 43 ++
crates/ui-egui/src/panels/properties.rs | 29 ++
docs/target-app-parity.md | 2 +-
27 files changed, 1327 insertions(+), 86 deletions(-)
create mode 100644 crates/ui-egui/src/guide_selection_tests.rs
diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs
index 2d28bf2c2..11c4e2ab1 100644
--- a/crates/doc/src/selection.rs
+++ b/crates/doc/src/selection.rs
@@ -2,9 +2,19 @@
use serde::{Deserialize, Serialize};
+use crate::edit::SpreadRef;
use crate::ids::{ItemId, StoryId};
use crate::table::CellRange;
+/// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GuideSel {
+ pub spread: SpreadRef,
+ pub page: usize,
+ pub index: usize,
+}
+
/// A table cell: the table (in the selection's story) and the owning cell's grid position.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -83,6 +93,10 @@ pub struct Selection {
/// The page selected with the Page tool (absolute index).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option,
+ /// Selected ruler guides. Never together with items, text or cells: selecting one clears
+ /// the other.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub guides: Vec,
}
impl Selection {
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 9ee963317..e5a2d2296 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -256,28 +256,39 @@ pub fn specs() -> Vec {
st.bump_revision();
ok()
}),
- cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text)", has_doc, |s, p| {
- if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
- return super::text::delete_selection(s);
- }
- let ids = super::targets(s, p)?;
- // With a placed graphic itself selected (Direct Selection), only the graphic goes: its
- // frame stays, empty and selected (as in InDesign). Other items are deleted.
- let content = s.doc()?.selection.content && p.get("ids").is_none();
- s.edit(|d, sel| {
- let mut kept = Vec::new();
- for id in &ids {
- if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
- it.content = designcraft_doc::Content::Unassigned;
- kept.push(*id);
- } else {
- let _ = d.remove_item(*id);
- }
+ cmd!(
+ "edit.clear",
+ "Clear",
+ ["Edit"],
+ Some("Delete"),
+ "{ids?} — delete selected items (or the selected text or ruler guides)",
+ has_doc,
+ |s, p| {
+ if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
+ return super::text::delete_selection(s);
}
- *sel = Selection::items(kept);
- Ok(json!({"deleted": ids.len()}))
- })
- }),
+ if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() {
+ return s.execute("guide.delete", &json!({}));
+ }
+ let ids = super::targets(s, p)?;
+ // With a placed graphic itself selected (Direct Selection), only the graphic goes: its
+ // frame stays, empty and selected (as in InDesign). Other items are deleted.
+ let content = s.doc()?.selection.content && p.get("ids").is_none();
+ s.edit(|d, sel| {
+ let mut kept = Vec::new();
+ for id in &ids {
+ if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
+ it.content = designcraft_doc::Content::Unassigned;
+ kept.push(*id);
+ } else {
+ let _ = d.remove_item(*id);
+ }
+ }
+ *sel = Selection::items(kept);
+ Ok(json!({"deleted": ids.len()}))
+ })
+ }
+ ),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| {
if let Some(text) = copy_text(s)? {
return Ok(json!({"text": text}));
diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs
index e8be163ef..97bf4e9cc 100644
--- a/crates/engine/src/cmd/guides.rs
+++ b/crates/engine/src/cmd/guides.rs
@@ -1,18 +1,28 @@
-//! Ruler guides: add (dragged from the rulers), move, delete, list; Layout › Create Guides.
+//! Ruler guides: add (dragged from the rulers), select, move, delete, list; Layout › Create
+//! Guides.
//!
//! Guides are stored on their page in spread coordinates; a spread guide (dropped on the
//! pasteboard) lives on the spread's first page with `spread: true` and crosses the pasteboard.
+//! Selected guides are [`Selection::guides`], addressed as (spread, page, index): the commands
+//! that delete guides renumber it, and [`keep_selection_valid`] drops what another command made
+//! stale.
-use designcraft_doc::{Document, Guide, Orientation, SpreadRef};
+use std::collections::{HashMap, HashSet};
+
+use designcraft_doc::{Document, Guide, GuideSel, Orientation, Selection, SpreadRef};
use designcraft_geom::Rect;
use serde_json::{Value, json};
-use super::{CommandSpec, bad, cmd, has_doc, str_param};
-use crate::{Result, Session};
+use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
+use crate::{DocState, Result, Session};
const MAX_GUIDE_DIVISIONS: u64 = 1000;
const MAX_GUIDES_CREATED: usize = 10_000;
const MAX_GUIDE_GUTTER: f64 = 1_000_000.0;
+/// The farthest a guide may be from the spread origin, in points, either way.
+const MAX_GUIDE_POSITION: f64 = 1_000_000.0;
+/// The most guides one `guides` list may name.
+const MAX_GUIDES_NAMED: usize = 10_000;
pub fn specs() -> Vec {
vec![
@@ -21,34 +31,87 @@ pub fn specs() -> Vec {
"New Guide",
[],
None,
- "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}",
+ "{orientation: horizontal|vertical, position (spread coordinate, at most 1000000 pt either way), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}. The new guides become the selection; objects are deselected",
has_doc,
add
),
- cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide),
- cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete),
+ cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position (at most 1000000 pt either way)}", has_doc, move_guide),
+ cmd!(
+ "guide.moveBy",
+ "Move Guides",
+ [],
+ None,
+ "{dx?, dy?, guides?: [{spread?, page, index}] (default: the selected guides)} — vertical guides move by dx, horizontal ones by dy, as one undo step",
+ has_doc,
+ move_by
+ ),
+ cmd!(
+ "guide.delete",
+ "Delete Guide",
+ [],
+ None,
+ "{spread?, page, index}, or {} for the selected guides; an address without page or index is an error",
+ has_doc,
+ delete
+ ),
cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| {
let r = super::spread_param(p, "guide.deleteAll")?;
- s.edit(|d, _| {
+ s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?;
let mut n = 0;
for pg in &mut sp.pages {
n += pg.guides.len();
pg.guides.clear();
}
+ sel.guides.retain(|g| g.spread != r);
Ok(json!({"deleted": n}))
})
}),
- cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| {
- let d = &s.doc()?.doc;
- let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?;
- let mut out = Vec::new();
- for (pi, pg) in sp.pages.iter().enumerate() {
- for (i, g) in pg.guides.iter().enumerate() {
- out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread}));
+ cmd!(
+ query "guide.list",
+ "Guides",
+ [],
+ None,
+ "{spread?} → [{page, index, orientation, position, spread, selected}]",
+ has_doc,
+ |s, p| {
+ let st = s.doc()?;
+ let r = super::spread_param(p, "guide.list")?;
+ let sp = st.doc.spread(r).ok_or_else(|| bad("guide.list", "no such spread"))?;
+ let picked: HashSet = st.selection.guides.iter().copied().collect();
+ let mut out = Vec::new();
+ for (pi, pg) in sp.pages.iter().enumerate() {
+ for (i, g) in pg.guides.iter().enumerate() {
+ let selected = picked.contains(&GuideSel { spread: r, page: pi, index: i });
+ out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread, "selected": selected}));
+ }
}
+ Ok(Value::Array(out))
}
- Ok(Value::Array(out))
+ ),
+ cmd!(
+ noundo "guide.select",
+ "Select Guide",
+ [],
+ None,
+ "{spread?, page, index, guides?: [{spread?, page, index}] (several, in place of page and index), add?: bool (keep the guides already selected), toggle?: bool (deselect a selected guide instead: Shift-click)} — selects ruler guides and deselects objects → {selected: n}",
+ has_doc,
+ select
+ ),
+ cmd!(
+ noundo "guide.selectAll",
+ "Select All Guides",
+ ["Edit"],
+ Some("Cmd+Alt+G"),
+ "{spread?} — every guide on the spread that is not locked or on a locked or hidden layer → {selected: n}",
+ has_doc,
+ select_all
+ ),
+ cmd!(noundo "guide.deselect", "Deselect Guides", [], None, "{}", has_doc, |s, _| {
+ let st = s.doc_mut()?;
+ st.selection.guides.clear();
+ st.bump_revision();
+ super::ok()
}),
cmd!(
"layout.createGuides",
@@ -80,6 +143,14 @@ fn finite_number(p: &Value, key: &str, command: &str) -> Result
+
+
+
+
DesignCraft
From d2bdb0866fb73cf9aac012dc4c08a8854ae2b92e Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:26:43 -0400
Subject: [PATCH 32/43] Paste: other apps' text goes into a text caret after
copying objects (#1344)
* Paste: other apps' text goes into a text caret even after copying objects (#212)
* Paste: text that matches a copied frame still pastes; groups with text frames aren't anchored (#212)
---
crates/engine/src/cmd/anchored.rs | 2 +-
crates/engine/src/cmd/edit.rs | 109 ++++++++++++++++++++++++++++--
crates/engine/src/cmd/mod.rs | 1 +
crates/engine/src/cmd/object.rs | 3 +-
crates/ui-egui/src/lib.rs | 31 +--------
5 files changed, 108 insertions(+), 38 deletions(-)
diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs
index dc7127459..6d09f89dd 100644
--- a/crates/engine/src/cmd/anchored.rs
+++ b/crates/engine/src/cmd/anchored.rs
@@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result {
}
/// Text frames can't be anchored yet (their stories need frames on a spread).
-fn check_item(it: &Item) -> Result<()> {
+pub(crate) fn check_item(it: &Item) -> Result<()> {
let mut ok = true;
it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_)));
if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) }
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index e5a2d2296..fb8848a90 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result
> {
Ok(Some(text))
}
+/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
+/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the
+/// copied objects", not as text.
+pub fn copied_objects_text(s: &Session) -> String {
+ let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() };
+ let mut stories = Vec::new();
+ for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() {
+ it.walk(&mut |i| {
+ if let Some(tf) = i.text_frame()
+ && !stories.contains(&tf.story)
+ {
+ stories.push(tf.story);
+ }
+ });
+ }
+ let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::>().join("\n");
+ if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
+}
+
+/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and
+/// it is their placeholder, so Paste stands for the copied objects.
+fn is_objects_placeholder(s: &Session, text: &str) -> bool {
+ s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n")
+}
+
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
+/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their
+/// placeholder; other text was copied in another app since. Objects that can't be anchored (text
+/// frames, into a table cell) paste as their placeholder text.
fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
- // Items copied last (no text copied since) paste into the text as anchored objects.
- if formatted
- && clip.is_none()
+ let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x));
+ if own
&& t.cell.is_none()
&& let Some(items) = s.clipboard.clone()
{
let list: Vec = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default();
- if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) {
+ if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) {
return s.edit(|d, sel| {
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
st.delete(r.start.min(st.len())..r.end.min(st.len()));
- let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?;
+ // Fresh ids, story and asset copies for every paste, as on the page.
+ let mut stories = std::collections::HashMap::new();
+ let fresh: Vec = list
+ .iter()
+ .map(|i| {
+ let mut i = i.clone();
+ super::object::renumber(d, &items, &mut i, &mut stories);
+ if d.layer(i.layer).is_none() {
+ i.layer = d.default_layer();
+ }
+ i
+ })
+ .collect();
+ let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?;
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "anchored": list.len()}))
});
@@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result<
Ok(json!({"pos": end, "formatted": true}))
});
}
+ // U+FFFC stands for objects with no text.
+ let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text };
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
@@ -327,7 +369,8 @@ pub fn specs() -> Vec {
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
- if let Some(text) = text {
+ // The placeholder of an object copy pastes the copied objects.
+ if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
@@ -538,6 +581,60 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.check().unwrap();
}
+ /// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212).
+ #[test]
+ fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let story = frame(&mut s, "ab");
+ let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone();
+ s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
+ s.execute("edit.copy", &json!({})).unwrap();
+ let marker = copied_objects_text(&s);
+ s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap();
+ let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap();
+ assert!(r.get("anchored").is_none(), "{r}");
+ assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere");
+ // What the object copy put on the system clipboard still pastes the objects.
+ assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1);
+ // With no insertion point too: the rectangle, not a text frame holding the marker.
+ s.execute("edit.deselectAll", &json!({})).unwrap();
+ let r = s.execute("edit.paste", &json!({"text": marker})).unwrap();
+ let id = ItemId(r["ids"][0].as_u64().unwrap());
+ assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none());
+ s.doc().unwrap().doc.check().unwrap();
+ // Paste without Formatting inserts the text given; empty text is no text.
+ s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap();
+ assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err());
+ }
+
+ /// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text.
+ #[test]
+ fn copied_text_frames_paste_at_a_caret_as_their_text() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let target = frame(&mut s, "ab");
+ let copy_and_paste = |s: &mut Session, ids: Value, text: Value| {
+ s.execute("selection.set", &json!({"ids": ids})).unwrap();
+ s.execute("edit.copy", &json!({})).unwrap();
+ s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap();
+ s.execute("edit.paste", &json!({"text": text})).unwrap()
+ };
+ let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone();
+ // The same text copied in another app, and the system clipboard read back as absent.
+ let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1"));
+ assert!(r.get("anchored").is_none(), "{r}");
+ s.execute("edit.paste", &json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab");
+ // A group with a text frame inside, its own placeholder on the system clipboard.
+ let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone();
+ let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone();
+ let r = copy_and_paste(&mut s, json!([group]), Value::Null);
+ assert!(r.get("anchored").is_none(), "{r}");
+ assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab");
+ s.doc().unwrap().doc.check().unwrap();
+ }
+
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index a4d5413bb..1d6294ea6 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -17,6 +17,7 @@ mod endnotes;
mod export;
mod file;
pub(crate) mod find;
+pub use edit::copied_objects_text;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
index 82f33a6d8..4ccb5c823 100644
--- a/crates/engine/src/cmd/object.rs
+++ b/crates/engine/src/cmd/object.rs
@@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc()))
}
-fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) {
+/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.
+pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 3674b3e81..0c155851f 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -810,7 +810,7 @@ impl DesignApp {
Some(t.to_string())
}
None if !self.native_menu => {
- self.object_clip_text = Some(self.copied_objects_text());
+ self.object_clip_text = Some(designcraft_engine::cmd::copied_objects_text(&self.session));
self.object_clip_text.clone()
}
None => {
@@ -910,35 +910,6 @@ impl DesignApp {
params
}
- /// Plain text for the system clipboard after copying objects: the copied text frames' stories,
- /// or U+FFFC (object replacement character) when none has text.
- fn copied_objects_text(&self) -> String {
- let mut stories = Vec::new();
- if let Some(clip) = &self.session.clipboard
- && let Some(sp) = clip.spreads.first()
- {
- for it in &sp.items {
- it.walk(&mut |i| {
- if let Some(tf) = i.text_frame()
- && !stories.contains(&tf.story)
- {
- stories.push(tf.story);
- }
- });
- }
- }
- let text = stories
- .iter()
- .filter_map(|id| self.session.clipboard.as_ref()?.story(*id))
- .map(|st| {
- st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect::()
- })
- .filter(|t| !t.is_empty())
- .collect::>()
- .join("\n");
- if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
- }
-
/// Handle requests produced by tools/commands (dialogs, view changes, file pickers).
pub fn after_engine(&mut self) {
let reqs = std::mem::take(&mut self.session.ui_requests);
From 1500b444b57a20d7d3fa7194faab94f853788bee Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:26:51 -0400
Subject: [PATCH 33/43] Hidden characters: trailing spaces show their dots
before the paragraph mark (#1345)
* Hidden characters: trailing spaces show their dots before the paragraph mark (#594)
* Hidden characters: skip glyphs whose offset isn't a character boundary
---
crates/ui-egui/src/canvas.rs | 77 ++++++++++++++++++++++++++----------
1 file changed, 57 insertions(+), 20 deletions(-)
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 9868d4d8b..ae6394bbd 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2716,33 +2716,47 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc
for l in &ft.lines {
let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32;
let font = egui::FontId::proportional(size);
- for g in &l.glyphs {
- if g.len == 0 {
- continue;
- }
- let ch = story.text[g.byte.min(story.text.len())..].chars().next().unwrap_or(' ');
- let mark = match ch {
- ' ' => "·",
- '\t' => "»",
- '\u{2028}' => "¬",
- '\u{A0}' => "°",
- _ => continue,
- };
- let p = xf.to_screen(m * Point::new(g.x + if ch == ' ' { g.adv / 2.0 } else { 0.0 }, l.baseline));
- let align = if ch == ' ' { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM };
+ for (x, mark, centered) in hidden_marks(&story.text, l) {
+ let p = xf.to_screen(m * Point::new(x, l.baseline));
+ let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM };
painter.text(p, align, mark, font.clone(), col);
}
- if l.last_in_para {
- let end = l.range.end;
- let mark = if end >= story.text.len() { "#" } else { "¶" };
- let p = xf.to_screen(m * Point::new(l.end_x + 1.0, l.baseline));
- painter.text(p, egui::Align2::LEFT_BOTTOM, mark, font.clone(), col);
- }
}
}
}
}
+/// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and
+/// whether the mark is centered on x (spaces) or starts at it.
+fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> {
+ let mut out = Vec::new();
+ for g in &l.glyphs {
+ if g.len == 0 {
+ continue;
+ }
+ let Some(ch) = text.get(g.byte..).and_then(|t| t.chars().next()) else { continue };
+ let mark = match ch {
+ ' ' => "·",
+ '\t' => "»",
+ '\u{2028}' => "¬",
+ '\u{A0}' => "°",
+ _ => continue,
+ };
+ if ch == ' ' {
+ out.push((g.x + g.adv / 2.0, mark, true));
+ } else {
+ out.push((g.x, mark, false));
+ }
+ }
+ if l.last_in_para {
+ // The spaces trimmed off the line's end sit past `end_x`; the paragraph mark follows them.
+ let end = l.glyphs.iter().filter(|g| g.len > 0 && !g.visible && g.x >= l.end_x - 1e-9).map(|g| g.x + g.adv).fold(l.end_x, f64::max);
+ let mark = if l.range.end >= text.len() { "#" } else { "¶" };
+ out.push((end + 1.0, mark, false));
+ }
+ out
+}
+
/// View › Flattener Preview: red over every object that involves transparency (opacity, blend
/// modes, effects, knockout) — what flattening or export rasterising affects.
fn draw_flattener_preview(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
@@ -5560,6 +5574,29 @@ mod tests {
assert_eq!(size, 18.0, "{key:?} applies the typed size");
}
}
+
+ #[test]
+ fn trailing_spaces_show_dots_before_the_paragraph_mark() {
+ let text = "ab \ncd ";
+ let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
+ let lid = d.default_layer();
+ let rect = DRect::new(0.0, 0.0, 300.0, 100.0);
+ let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), rect, lid, text, Default::default()).unwrap();
+ let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default());
+ let lines: Vec<_> = cs.frames.iter().flat_map(|f| &f.lines).collect();
+ assert_eq!(lines.len(), 2);
+ for (l, end_mark) in lines.iter().zip(["¶", "#"]) {
+ let marks = hidden_marks(text, l);
+ let dots: Vec = marks.iter().filter(|m| m.1 == "·").map(|m| m.0).collect();
+ assert_eq!(dots.len(), 2, "a dot for each trailing space: {marks:?}");
+ let &(para_x, mark, _) = marks.last().unwrap();
+ assert_eq!(mark, end_mark);
+ // The dots sit past the last letter, and the mark comes after both spaces.
+ let space = l.glyphs.iter().rev().find(|g| g.len > 0).unwrap();
+ assert!(dots.iter().all(|&x| x > l.end_x && x < para_x), "{dots:?} then {mark} at {para_x}");
+ assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space");
+ }
+ }
}
#[cfg(test)]
From 291b39a1f30ee7b2df53dab6c18cc78e141468ac Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:27:00 -0400
Subject: [PATCH 34/43] Edit: the Paste in Place shortcut pastes in place
(#1346)
* Edit: the Paste in Place shortcut pastes in place
* Edit: the Paste in Place chord handling is native-only (web gets the key press)
---
crates/ui-egui/src/canvas.rs | 71 ++++++++++++++++++++++++++++++++++++
crates/ui-egui/src/menus.rs | 14 +++++++
2 files changed, 85 insertions(+)
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index ae6394bbd..318f82388 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -1583,6 +1583,21 @@ fn field_focused_id(ctx: &egui::Context) -> egui::Id {
pub fn begin_frame(ctx: &egui::Context) {
let (id, focused) = (field_focused_id(ctx), ctx.text_edit_focused());
ctx.data_mut(|d| d.insert_temp(id, focused));
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let now = ctx.input(|i| i.modifiers);
+ // The modifiers last frame ended with are the ones this frame's events began with.
+ ctx.data_mut(|d| {
+ let (_, end) = d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id()).unwrap_or((now, now));
+ d.insert_temp(modifiers_id(), (end, now));
+ });
+ }
+}
+
+/// (held when this frame's events began, held after them).
+#[cfg(not(target_arch = "wasm32"))]
+fn modifiers_id() -> egui::Id {
+ egui::Id::new("canvas-frame-modifiers")
}
/// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin).
@@ -2546,7 +2561,21 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
// text must not delete the character before it).
let composing_at_start = app.session.tool_composing();
let evs = ui.input(|i| i.events.clone());
+ // Native only: egui-winit sends a ⌘+V chord as a paste without its key press, so the modifiers
+ // held at the paste tell Paste in Place from Paste. The web also gets the key press, and the
+ // shortcut table has already run the command. A chord's modifiers may be released in the same
+ // frame, hence the modifiers held at each event.
+ #[cfg(not(target_arch = "wasm32"))]
+ let mut held =
+ ui.data(|d| d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id())).map_or_else(|| ui.input(|i| i.modifiers), |(start, _)| start);
for e in evs {
+ #[cfg(not(target_arch = "wasm32"))]
+ let chord = {
+ if let egui::Event::ModifiersChanged(m) = &e {
+ held = *m;
+ }
+ if matches!(e, egui::Event::Paste(_)) { crate::menus::paste_chord_command(app, held) } else { None }
+ };
let composing = composing_at_start || app.session.tool_composing();
match e {
egui::Event::Ime(ime) if wants_text => ime_event(app, ime, vi),
@@ -2568,6 +2597,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" };
let _ = app.run(id, json!({}));
}
+ // Paste in Place's chord (⌘⌥⇧V unless rebound) arrives as a paste too.
+ #[cfg(not(target_arch = "wasm32"))]
+ egui::Event::Paste(_) if chord.is_some() => {
+ if let Some(id) = chord
+ && crate::menus::enabled(app, id)
+ {
+ crate::menus::activate(app, id, &serde_json::Value::Null);
+ }
+ }
// Without a native menu bar the copy and paste keys arrive only as these events.
egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) if !app.native_menu => {
let (id, params) = match e {
@@ -4278,6 +4316,39 @@ mod tests {
assert!((it.bounds().center() - centre).hypot() < 0.01, "{:?} vs {centre:?}", it.bounds());
}
+ #[test]
+ fn the_paste_in_place_shortcut_pastes_in_place_and_plain_paste_offsets() {
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({})).unwrap();
+ let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
+ let original = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds();
+ app.run("edit.copy", json!({})).unwrap();
+ let clip = app.object_clip_text.clone().expect("the copy's placeholder text");
+ let mut h = harness(app);
+ let paste = |h: &mut Harness<'static, DesignApp>, modifiers: egui::Modifiers| {
+ // As egui-winit delivers ⌃⌥⇧V: the chord's paste and the V release, no V press; here the
+ // modifiers are released within the same frame.
+ let release = egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers };
+ let none = egui::Event::ModifiersChanged(egui::Modifiers::NONE);
+ h.input_mut().events.extend([egui::Event::ModifiersChanged(modifiers), egui::Event::Paste(clip.clone()), release, none]);
+ h.run_steps(2);
+ let st = h.state().session.doc().unwrap();
+ let pasted = *st.selection.items.first().expect("the copy is selected");
+ assert_ne!(pasted.0, id);
+ st.doc.item(pasted).unwrap().bounds()
+ };
+ let linux = egui::Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true };
+ assert_eq!(paste(&mut h, linux), original, "Paste in Place");
+ let moved = paste(&mut h, egui::Modifiers::COMMAND);
+ assert!((moved.x0 - original.x0 - 12.0).abs() < 1e-9 && (moved.y0 - original.y0 - 12.0).abs() < 1e-9, "{moved:?}");
+ // The modifiers went down in an earlier frame than the paste.
+ h.event_modifiers(egui::Event::Paste(clip.clone()), linux);
+ h.run_steps(2);
+ let st = h.state().session.doc().unwrap();
+ let pasted = *st.selection.items.first().expect("the copy is selected");
+ assert_eq!(st.doc.item(pasted).unwrap().bounds(), original);
+ }
+
#[test]
fn edit_paste_from_the_menu_pastes_svg_on_the_system_clipboard_as_paths() {
let services = crate::Services { clipboard_text: Some(Box::new(|| Some(ART.to_string()))), ..Default::default() };
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index fb43f8cb9..af2a91fc3 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -3050,6 +3050,20 @@ fn key_matches(m: egui::Modifiers, k: egui::Key, key: egui::Key, modifiers: egui
matches.then_some(k == key && m.shift == modifiers.shift)
}
+/// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into
+/// `Event::Paste` with the clipboard's text and drops the key press, so the shortcut table never
+/// sees Paste in Place's chord; the modifiers held when it went down tell them apart. `None` when
+/// the chord is another one, or the native menu's accelerator takes it.
+#[cfg(not(target_arch = "wasm32"))]
+pub fn paste_chord_command(app: &DesignApp, modifiers: egui::Modifiers) -> Option<&'static str> {
+ let id = "edit.pasteInPlace";
+ if app.native_menu && !app.ui.shortcuts.contains_key(id) {
+ return None;
+ }
+ let (m, k) = shortcut_of(app, id).as_deref().and_then(parse_shortcut)?;
+ (k == egui::Key::V && key_matches(m, k, egui::Key::V, modifiers) == Some(true)).then_some(id)
+}
+
/// Global keyboard shortcuts: menu commands and single-key tool shortcuts.
pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
// Only a focused text field takes the keys: the canvas (or a button) having focus after a click
From 8f98f3dc8bfac09d69f949c39bf97f0861621b6c Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:28:11 -0400
Subject: [PATCH 35/43] Keyboard Shortcuts: import an InDesign shortcut set
(#1296)
* #1180: import an InDesign keyboard shortcut set
Edit > Keyboard Shortcuts gets Import InDesign Set..., which reads the
.txt that InDesign's Show Set writes (window.importShortcuts {path|text}).
Matched commands take the set's Default shortcut, or lose theirs when
every occurrence says [none defined]; other commands give up keys the
set takes. The report lists changes, cleared conflicts, and the items
that could not be matched or read. Tool keys stay fixed.
* #1180: read the set file through the checked reader
* #1180: when two items want the same keys, the later one takes them
---
ROADMAP.md | 1 +
apps/designcraft-web/src/web.rs | 2 +
apps/designcraft/src/main.rs | 1 +
crates/ui-egui/src/dialogs.rs | 13 +-
crates/ui-egui/src/lib.rs | 5 +
crates/ui-egui/src/menus.rs | 19 +-
crates/ui-egui/src/shortcut_import.rs | 611 ++++++++++++++++++++++++++
docs/file-format-parity.md | 2 +-
docs/target-app-parity.md | 2 +-
docs/ui-parity.md | 2 +-
10 files changed, 651 insertions(+), 7 deletions(-)
create mode 100644 crates/ui-egui/src/shortcut_import.rs
diff --git a/ROADMAP.md b/ROADMAP.md
index 8a2352e86..d3ef17e47 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs
index fe0f4a7fa..27d236a2f 100644
--- a/apps/designcraft-web/src/web.rs
+++ b/apps/designcraft-web/src/web.rs
@@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
let dialog = if request.purpose == "swatches" {
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
+ } else if request.purpose == "shortcuts" {
+ rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"])
} else if request.purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs
index b56878176..13416e4e0 100644
--- a/apps/designcraft/src/main.rs
+++ b/apps/designcraft/src/main.rs
@@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
match purpose {
"swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }],
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
+ "shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 8b4043787..6b60b1296 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -315,9 +315,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
if !msg.is_empty() {
ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60)));
}
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() {
- let _ = app.run("window.resetShortcuts", json!({}));
- }
+ ui.horizontal(|ui| {
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() {
+ let _ = app.run("window.resetShortcuts", json!({}));
+ }
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import InDesign Set…"))).clicked()
+ && let Some(msg) = crate::shortcut_import::pick_and_import(app)
+ {
+ d.fields.insert("message".into(), json!(msg));
+ }
+ });
}
/// File › Print, with the printers the system knows.
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 0c155851f..18fa402a5 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -27,6 +27,7 @@ mod prefs;
pub mod render_worker;
mod rtl;
mod section_options;
+mod shortcut_import;
pub mod story_editor;
pub mod taskbar;
pub mod theme;
@@ -1033,6 +1034,10 @@ impl DesignApp {
let Some(inbox) = self.services.inbox.clone() else { return };
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
for file in files {
+ if file.request.purpose == "shortcuts" {
+ shortcut_import::import_delivered(self, &file.bytes);
+ continue;
+ }
if self.ui.dialog.is_some() {
self.status(
crate::i18n::tr(&self.ui.language, "{name}: import cancelled while a dialog is open; choose the file again")
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index af2a91fc3..2a271324a 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -85,6 +85,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("window.richBlack", "Appearance of Black", None, "{on: bool} — show 100% black as rich black on screen (off: accurately)"),
("window.setShortcut", "Set Shortcut", None, "{id, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default)} → {shortcut, conflicts: [ids]}"),
("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"),
+ (
+ "window.importShortcuts",
+ "Import InDesign Shortcut Set",
+ None,
+ "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}",
+ ),
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),
(
@@ -1705,6 +1711,17 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option {
+ let text = match (p.get("text").and_then(Value::as_str), p.get("path").and_then(Value::as_str)) {
+ (Some(text), _) => text.to_string(),
+ (None, Some(path)) => match crate::shortcut_import::read_file(path) {
+ Ok(text) => text,
+ Err(e) => return Some(Err(e)),
+ },
+ (None, None) => return Some(Err("give the set's path or text".into())),
+ };
+ crate::shortcut_import::import(app, &text)
+ }
"view.snapPreferences" => {
let b = |k: &str| p.get(k).and_then(Value::as_bool);
let ui = &mut app.ui;
@@ -3009,7 +3026,7 @@ pub fn all_menu_items() -> Vec<(String, String)> {
out
}
-fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> {
+pub(crate) fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> {
let mut m = egui::Modifiers::NONE;
let mut key = None;
for part in sc.split('+').filter(|s| !s.is_empty()) {
diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs
new file mode 100644
index 000000000..9ccbef1b9
--- /dev/null
+++ b/crates/ui-egui/src/shortcut_import.rs
@@ -0,0 +1,611 @@
+//! Importing a keyboard shortcut set that InDesign's Keyboard Shortcuts dialog saved as text
+//! (Show Set): `window.importShortcuts`.
+//!
+//! The file lists items under `Product area : ` headers, one per line:
+//! ` --- [none defined]` or ` --- : , : `. Item names
+//! use `: ` between submenu levels. Only the Default context applies (DesignCraft has no
+//! shortcut contexts); a type command with no Default entry takes its Text entry.
+
+use std::collections::{BTreeSet, HashMap};
+
+use serde_json::{Value, json};
+
+use crate::DesignApp;
+use crate::menus::{self, Item};
+
+/// Largest set file read. InDesign's full set is a few hundred kilobytes (twice that as UTF-16).
+pub const MAX_BYTES: usize = 8 << 20;
+/// Entries read per item: a real item has one or two per context.
+const MAX_ENTRIES: usize = 64;
+
+/// One `: ` entry of an item.
+struct Entry {
+ context: String,
+ keys: String,
+}
+
+struct SetItem {
+ area: String,
+ name: String,
+ entries: Vec,
+}
+
+struct ParsedSet {
+ name: String,
+ items: Vec,
+}
+
+/// Text from the bytes of a set file: UTF-8 (with or without a BOM) or UTF-16 (by its BOM, or
+/// by the zero bytes of ASCII text without one).
+pub fn decode(bytes: &[u8]) -> Result {
+ if bytes.len() > MAX_BYTES {
+ return Err(format!("the file is larger than {} MB", MAX_BYTES >> 20));
+ }
+ let utf16 = |rest: &[u8], le: bool| -> String {
+ let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
+ char::decode_utf16(units).map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
+ };
+ Ok(match bytes {
+ [0xFF, 0xFE, rest @ ..] => utf16(rest, true),
+ [0xFE, 0xFF, rest @ ..] => utf16(rest, false),
+ [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
+ [a, 0, ..] if *a != 0 => utf16(bytes, true),
+ [0, b, ..] if *b != 0 => utf16(bytes, false),
+ _ => String::from_utf8_lossy(bytes).into_owned(),
+ })
+}
+
+/// Reads and decodes a set file, refusing one over [`MAX_BYTES`].
+pub fn read_file(path: &str) -> Result {
+ let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX);
+ let bytes = designcraft_engine::io::read_capped(path, cap).map_err(|e| format!("can't read {path}: {e}"))?;
+ decode(&bytes)
+}
+
+/// `Label : value` with any spacing around the colon, when the line starts with `label`.
+fn header<'a>(line: &'a str, label: &str) -> Option<&'a str> {
+ let rest = line.get(..label.len()).filter(|h| h.eq_ignore_ascii_case(label)).and_then(|_| line.get(label.len()..))?;
+ rest.trim_start().strip_prefix(':').map(str::trim)
+}
+
+fn parse(text: &str) -> Result {
+ let text = text.strip_prefix('\u{feff}').unwrap_or(text);
+ let mut set = ParsedSet { name: String::new(), items: Vec::new() };
+ let mut area = String::new();
+ let mut header_seen = false;
+ for line in text.lines() {
+ let line = line.trim_end_matches('\r');
+ let trimmed = line.trim();
+ if let Some(name) = header(trimmed, "Shortcut Set") {
+ set.name = name.to_string();
+ header_seen = true;
+ } else if let Some(a) = header(trimmed, "Product area") {
+ area = a.to_string();
+ header_seen = true;
+ } else if let Some((name, keys)) = trimmed.split_once(" --- ") {
+ let name = name.trim();
+ if name.is_empty() {
+ continue;
+ }
+ set.items.push(SetItem { area: area.clone(), name: name.to_string(), entries: entries(keys) });
+ }
+ }
+ if set.items.is_empty() && !header_seen {
+ return Err("not a shortcut set: no `Product area` or ` --- ` lines".into());
+ }
+ Ok(set)
+}
+
+/// The entries after ` --- `: none for `[none defined]`. A comma is also a key (`Ctrl+,`), so a
+/// piece without `: ` belongs to the entry before it.
+fn entries(keys: &str) -> Vec {
+ let keys = keys.trim();
+ if keys.eq_ignore_ascii_case("[none defined]") || keys.is_empty() {
+ return Vec::new();
+ }
+ let mut pieces: Vec = Vec::new();
+ for piece in keys.split(", ") {
+ match pieces.last_mut() {
+ Some(last) if !piece.contains(": ") => {
+ last.push_str(", ");
+ last.push_str(piece);
+ }
+ _ => pieces.push(piece.to_string()),
+ }
+ }
+ pieces
+ .into_iter()
+ .take(MAX_ENTRIES)
+ .filter_map(|p| {
+ let (context, keys) = p.split_once(": ")?;
+ Some(Entry { context: context.trim().to_string(), keys: keys.trim().to_string() })
+ })
+ .collect()
+}
+
+/// Whether the keys use Mac modifier names, where Ctrl is the Control key rather than the
+/// command modifier.
+fn is_mac_set(set: &ParsedSet) -> bool {
+ set.items.iter().flat_map(|i| &i.entries).any(|e| {
+ let k = e.keys.to_lowercase();
+ k.contains("cmd") || k.contains("command") || k.contains('⌘') || k.contains("opt") || k.contains('⌥')
+ })
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Mod {
+ Command,
+ Alt,
+ Shift,
+ Control,
+}
+
+/// Keys as InDesign writes them (`Shift+Ctrl+S`, `Ctrl+Num 5`, `Page Down`, `⇧⌘S`) as a
+/// DesignCraft shortcut string (`Cmd+Shift+S`). Ctrl is the command modifier in a Windows set.
+fn read_keys(keys: &str, mac: bool) -> Result {
+ const SYMBOLS: &[(&str, Mod)] = &[("⌘", Mod::Command), ("⌥", Mod::Alt), ("⇧", Mod::Shift), ("⌃", Mod::Control)];
+ const WORDS: &[(&str, Mod)] = &[
+ ("command", Mod::Command),
+ ("cmd", Mod::Command),
+ ("option", Mod::Alt),
+ ("opt", Mod::Alt),
+ ("alt", Mod::Alt),
+ ("shift", Mod::Shift),
+ ("control", Mod::Control),
+ ("ctrl", Mod::Control),
+ ];
+ let mut m = egui::Modifiers::NONE;
+ let mut set = |md: Mod| match md {
+ Mod::Command => m.command = true,
+ Mod::Alt => m.alt = true,
+ Mod::Shift => m.shift = true,
+ Mod::Control if mac => m.ctrl = true,
+ Mod::Control => m.command = true,
+ };
+ let mut rest = keys.trim();
+ 'strip: loop {
+ for (sym, md) in SYMBOLS {
+ if let Some(r) = rest.strip_prefix(sym) {
+ set(*md);
+ rest = r.strip_prefix('+').unwrap_or(r).trim_start();
+ continue 'strip;
+ }
+ }
+ for (word, md) in WORDS {
+ let Some(head) = rest.get(..word.len()) else { continue };
+ let Some(after) = rest.get(word.len()..) else { continue };
+ // The word must be followed by a separator and a key: "Alt" alone, or "Altx", is a key name.
+ let mut chars = after.chars();
+ if head.eq_ignore_ascii_case(word) && matches!(chars.next(), Some('+' | ' ' | '-')) && !chars.as_str().is_empty() {
+ set(*md);
+ rest = chars.as_str().trim_start();
+ continue 'strip;
+ }
+ }
+ break;
+ }
+ if m.ctrl {
+ return Err("Control-key shortcuts are not supported");
+ }
+ let key = key_named(rest.trim()).ok_or("unknown key")?;
+ let sc = menus::shortcut_string(m, key);
+ // Only a string the shortcut dispatcher reads back as these keys is imported.
+ match menus::parse_shortcut(&sc) {
+ Some((pm, pk)) if pk == key && same_mods(pm, m) => Ok(sc),
+ _ => Err("unknown key"),
+ }
+}
+
+fn same_mods(a: egui::Modifiers, b: egui::Modifiers) -> bool {
+ a.command == b.command && a.alt == b.alt && a.shift == b.shift && a.ctrl == b.ctrl
+}
+
+/// A key by the name InDesign gives it (Windows words, Mac symbols), case- and space-insensitive.
+fn key_named(name: &str) -> Option {
+ use egui::Key;
+ if name.is_empty() {
+ return None;
+ }
+ let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().to_lowercase();
+ // The numeric keypad: egui doesn't tell its keys apart from the main ones.
+ let n = squashed.strip_prefix("numpad").or_else(|| squashed.strip_prefix("num").filter(|r| !r.is_empty() && *r != "lock")).unwrap_or(&squashed);
+ let key = match n {
+ "up" | "uparrow" | "arrowup" | "↑" => Key::ArrowUp,
+ "down" | "downarrow" | "arrowdown" | "↓" => Key::ArrowDown,
+ "left" | "leftarrow" | "arrowleft" | "←" => Key::ArrowLeft,
+ "right" | "rightarrow" | "arrowright" | "→" => Key::ArrowRight,
+ "pageup" | "pgup" | "⇞" => Key::PageUp,
+ "pagedown" | "pgdn" | "⇟" => Key::PageDown,
+ "home" | "↖" => Key::Home,
+ "end" | "↘" => Key::End,
+ "esc" | "escape" | "⎋" => Key::Escape,
+ "tab" | "⇥" => Key::Tab,
+ "space" | "spacebar" => Key::Space,
+ "enter" | "return" | "↩" | "⏎" | "⌤" => Key::Enter,
+ "backspace" | "⌫" => Key::Backspace,
+ "delete" | "del" | "forwarddelete" | "⌦" => Key::Delete,
+ "insert" | "ins" | "help" => Key::Insert,
+ _ => return Key::from_name(n).or_else(|| Key::from_name(&n.to_uppercase())).or_else(|| Key::from_name(name)),
+ };
+ Some(key)
+}
+
+/// Matching form of a label or menu name: no ellipsis, no `&`, single spaces, lower case, and
+/// no Show/Hide in front (InDesign names a toggle by what choosing it does now).
+fn norm(s: &str) -> String {
+ let s = s.replace('…', "").replace("...", "").replace('&', "");
+ let s = s.split_whitespace().collect::>().join(" ").to_lowercase();
+ ["show/hide ", "show ", "hide "].iter().find_map(|p| s.strip_prefix(p).filter(|r| !r.is_empty())).map_or(s.clone(), str::to_string)
+}
+
+/// DesignCraft's commands by menu path and label.
+#[derive(Default)]
+struct Index {
+ /// `menu\nsubmenu\nlabel` → ids, in the menu bar.
+ path: HashMap>,
+ /// The same from the engine commands' own menu placement, which a UI command (the one with
+ /// the dialog) may stand in for in the menu bar.
+ engine_path: HashMap>,
+ /// (menu, label) → ids, at any depth of that menu.
+ in_menu: HashMap<(String, String), BTreeSet>,
+ /// label → ids, anywhere.
+ label: HashMap>,
+}
+
+impl Index {
+ fn build() -> Self {
+ fn walk(ix: &mut Index, items: &[Item], path: &mut Vec) {
+ for it in items {
+ match it {
+ Item::Sub(name, children) => {
+ path.push(norm(name));
+ walk(ix, children, path);
+ path.pop();
+ }
+ // A shortcut runs the command without parameters; a fixed-parameter variant can't hold one.
+ Item::Cmd { label, id, params, .. } if params.is_null() => {
+ let label = norm(label);
+ let top = path.first().cloned().unwrap_or_default();
+ add_path(&mut ix.path, path, &label, id);
+ ix.in_menu.entry((top, label.clone())).or_default().insert(id.clone());
+ ix.label.entry(label).or_default().insert(id.clone());
+ }
+ _ => {}
+ }
+ }
+ }
+ let mut ix = Index::default();
+ for (menu, entries) in menus::menu_tree() {
+ walk(&mut ix, &entries, &mut vec![norm(menu)]);
+ }
+ for c in designcraft_engine::command_specs() {
+ let label = norm(c.label);
+ if !c.menu.is_empty() {
+ let path: Vec = c.menu.iter().map(|m| norm(m)).collect();
+ add_path(&mut ix.engine_path, &path, &label, c.id);
+ }
+ ix.label.entry(label).or_default().insert(c.id.to_string());
+ }
+ for (id, label, _, _) in menus::UI_COMMANDS {
+ ix.label.entry(norm(label)).or_default().insert((*id).to_string());
+ }
+ ix
+ }
+
+ /// The command an item names, trying the most specific match first. Matches by label alone
+ /// only count when exactly one command has that label.
+ fn find(&self, area: &str, name: &str) -> Option {
+ let one = |s: Option<&BTreeSet>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned());
+ let segments: Vec = name.split(": ").map(norm).collect();
+ let last = segments.last().cloned().unwrap_or_default();
+ let area = norm(area);
+ if let Some(menu) = area.strip_suffix(" menu").filter(|m| !m.contains(':')) {
+ let mut key = menu.to_string();
+ for s in &segments {
+ key.push('\n');
+ key.push_str(s);
+ }
+ if let Some(id) = one(self.path.get(&key)) {
+ return Some(id);
+ }
+ if segments.len() > 1
+ && let Some(id) = one(self.in_menu.get(&(menu.to_string(), segments.join(" "))))
+ {
+ return Some(id);
+ }
+ if let Some(id) = one(self.engine_path.get(&key)) {
+ return Some(id);
+ }
+ if let Some(id) = one(self.in_menu.get(&(menu.to_string(), last.clone()))) {
+ return Some(id);
+ }
+ // Menus are arranged differently: the item's own label, if no other command has it.
+ return one(self.label.get(&last));
+ }
+ if segments.len() == 1 { one(self.label.get(&last)) } else { None }
+ }
+}
+
+fn add_path(map: &mut HashMap>, path: &[String], label: &str, id: &str) {
+ let mut key = path.join("\n");
+ key.push('\n');
+ key.push_str(label);
+ map.entry(key).or_default().insert(id.to_string());
+}
+
+/// What the set says about one DesignCraft command, over every item matched to it.
+struct Decision {
+ id: String,
+ item: String,
+ /// The first readable shortcut.
+ shortcut: Option,
+ /// Every matched item said `[none defined]`.
+ all_none: bool,
+}
+
+fn command_label(id: &str) -> String {
+ menus::ui_label(id)
+ .map(|(l, _)| l.to_string())
+ .or_else(|| designcraft_engine::find_command(id).map(|c| c.label.to_string()))
+ .unwrap_or_else(|| id.to_string())
+}
+
+fn keys_of(sc: &str) -> Option<(bool, bool, bool, bool, egui::Key)> {
+ menus::parse_shortcut(sc).map(|(m, k)| (m.command, m.alt, m.shift, m.ctrl, k))
+}
+
+/// Imports a set: every matched command takes the set's shortcut (or none), another command
+/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report.
+pub fn import(app: &mut DesignApp, text: &str) -> Result {
+ if text.len() > MAX_BYTES {
+ return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20));
+ }
+ let set = parse(text)?;
+ let mac = is_mac_set(&set);
+ let ix = Index::build();
+ let mut decisions: Vec = Vec::new();
+ let mut unmatched = Vec::new();
+ let mut unmatched_without_shortcut = 0usize;
+ let mut unreadable = Vec::new();
+ for item in &set.items {
+ let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) };
+ let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::>().join(", ");
+ let Some(id) = ix.find(&item.area, &item.name) else {
+ if item.entries.is_empty() {
+ unmatched_without_shortcut += 1;
+ } else {
+ let tool = norm(&item.area) == "tools"
+ && designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name));
+ let mut row = json!({"item": shown, "keys": all_keys});
+ if tool {
+ row["reason"] = json!("tool keys are fixed");
+ }
+ unmatched.push(row);
+ }
+ continue;
+ };
+ let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>();
+ let text_command = id.starts_with("type.") || id.starts_with("text.");
+ let mut considered = in_context("Default");
+ if considered.is_empty() && text_command {
+ considered = in_context("Text");
+ }
+ let mut read = None;
+ let mut why = "";
+ for e in &considered {
+ match read_keys(&e.keys, mac) {
+ Ok(sc) => {
+ read = Some(sc);
+ break;
+ }
+ Err(w) => why = w,
+ }
+ }
+ if read.is_none() && !considered.is_empty() {
+ unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why}));
+ }
+ let pos = match decisions.iter().position(|d| d.id == id) {
+ Some(p) => p,
+ None => {
+ decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true });
+ decisions.len() - 1
+ }
+ };
+ let Some(d) = decisions.get_mut(pos) else { continue };
+ d.all_none &= item.entries.is_empty();
+ if d.shortcut.is_none()
+ && let Some(sc) = read
+ {
+ d.shortcut = Some(sc);
+ d.item = shown;
+ }
+ }
+
+ let before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect();
+ let mut imported = 0usize;
+ let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new();
+ let mut skipped = Vec::new();
+ let mut decided: BTreeSet = BTreeSet::new();
+ // When two items in the set want the same keys, the later one takes them (the rule shared by
+ // the Craft apps' importers).
+ let mut winner: HashMap<(bool, bool, bool, bool, egui::Key), usize> = HashMap::new();
+ for (i, d) in decisions.iter().enumerate() {
+ if let Some(keys) = d.shortcut.as_deref().and_then(keys_of) {
+ winner.insert(keys, i);
+ }
+ }
+ for (i, d) in decisions.iter().enumerate() {
+ match &d.shortcut {
+ Some(sc) => {
+ let Some(keys) = keys_of(sc) else { continue };
+ if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) {
+ skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id}));
+ continue;
+ }
+ taken.push((keys, d.id.clone()));
+ if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) {
+ app.ui.shortcuts.remove(&d.id);
+ } else {
+ app.ui.shortcuts.insert(d.id.clone(), sc.clone());
+ }
+ }
+ None if d.all_none => {
+ if menus::default_shortcut(&d.id).is_none() {
+ app.ui.shortcuts.remove(&d.id);
+ } else {
+ app.ui.shortcuts.insert(d.id.clone(), String::new());
+ }
+ }
+ None => continue,
+ }
+ imported += 1;
+ decided.insert(d.id.clone());
+ }
+ let mut conflicts = Vec::new();
+ for (id, sc) in menus::effective_shortcuts(app) {
+ if decided.contains(&id) {
+ continue;
+ }
+ // A command sharing its default keys with the one taking them (file.new and the New
+ // Document dialog) keeps them: the dispatcher already prefers the other.
+ let twin = |by: &str| {
+ let default = |id: &str| menus::default_shortcut(id).and_then(keys_of);
+ default(&id).is_some() && default(&id) == default(by)
+ };
+ if let Some(keys) = keys_of(&sc)
+ && taken.iter().any(|(k, by)| *k == keys && !twin(by))
+ {
+ app.ui.shortcuts.insert(id.clone(), String::new());
+ conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc}));
+ }
+ }
+ let changed: Vec = decisions
+ .iter()
+ .filter(|d| decided.contains(&d.id))
+ .filter_map(|d| {
+ let from = before.get(&d.id).cloned().flatten();
+ let to = menus::shortcut_of(app, &d.id);
+ (from != to).then(|| json!({"id": d.id, "label": command_label(&d.id), "item": d.item, "from": from, "to": to}))
+ })
+ .collect();
+ Ok(json!({
+ "set": set.name,
+ "imported": imported,
+ "changed": changed,
+ "conflictsCleared": conflicts,
+ "skipped": skipped,
+ "unmatched": unmatched,
+ "unmatchedWithoutShortcut": unmatched_without_shortcut,
+ "unreadable": unreadable,
+ }))
+}
+
+/// One line for the Keyboard Shortcuts dialog about an import's report.
+pub fn summary(lang: &str, report: &Value) -> String {
+ let n = |k: &str| report[k].as_u64().unwrap_or_default().to_string();
+ let len = |k: &str| report[k].as_array().map_or(0, Vec::len).to_string();
+ crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.")
+ .replace("{count}", &n("imported"))
+ .replace("{set}", report["set"].as_str().unwrap_or_default())
+ .replace("{unmatched}", &len("unmatched"))
+ .replace("{unreadable}", &len("unreadable"))
+}
+
+/// The Keyboard Shortcuts dialog's Import button: a native file picker imports at once and
+/// returns the summary; in a browser the chosen file arrives later through the import inbox.
+pub fn pick_and_import(app: &mut DesignApp) -> Option {
+ if let Some(pick) = app.services.pick_open.as_mut() {
+ let path = pick("shortcuts")?;
+ return Some(match app.run("window.importShortcuts", json!({"path": path})) {
+ Ok(report) => summary(&app.ui.language, &report),
+ Err(e) => e,
+ });
+ }
+ let request = app.import_request("shortcuts");
+ if let Some(open) = app.services.open_async.as_mut() {
+ open(request);
+ }
+ None
+}
+
+/// A set file chosen in the browser: import it and say how it went in the open Keyboard
+/// Shortcuts dialog, or the status bar.
+pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) {
+ let msg = match decode(bytes).and_then(|text| app.run("window.importShortcuts", json!({"text": text}))) {
+ Ok(report) => summary(&app.ui.language, &report),
+ Err(e) => e,
+ };
+ match app.ui.dialog.as_mut().filter(|d| d.id == "keyboardShortcuts") {
+ Some(d) => {
+ d.fields.insert("message".into(), json!(msg));
+ }
+ None => app.status(msg),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn app() -> DesignApp {
+ DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())
+ }
+
+ #[test]
+ fn imports_a_windows_set_and_reports_what_it_could_not_use() {
+ let mut app = app();
+ // Something else holds Ctrl+Alt+K before the import; the set gives it to Check Spelling.
+ menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Alt+K"})).unwrap().unwrap();
+ let set = "\u{feff}Shortcut Set : My Set\r\n\r\n\
+ Product area : Edit Menu\r\n\
+ \tSpelling: Check Spelling... --- Default: Ctrl+Alt+K, Text: F7\r\n\
+ \tDuplicate --- [none defined]\r\n\
+ \tDuplicate --- [none defined]\r\n\
+ \tFrobnicate Everything --- Default: Ctrl+F12\r\n\
+ \tNever Heard Of It --- [none defined]\r\n\
+ Product area : File Menu\r\n\
+ \tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\
+ \tSave --- Default: Ctrl+Hyperspace\r\n\
+ \tRevert --- Default: Shift+Ctrl+F9\r\n\
+ \tSave As... --- Default: Shift+Ctrl+F9\r\n\
+ garbage line without a separator\r\n";
+ let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap();
+ assert_eq!(r["set"], "My Set");
+ // A submenu item, with Ctrl as the command modifier.
+ assert_eq!(menus::shortcut_of(&app, "spelling.check").as_deref(), Some("Cmd+Alt+K"));
+ assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+5"));
+ // `[none defined]` in every occurrence clears a matched command.
+ assert_eq!(menus::shortcut_of(&app, "edit.duplicate"), None);
+ // The command that held the imported keys loses them.
+ assert_eq!(menus::shortcut_of(&app, "app.palette"), None);
+ assert!(r["conflictsCleared"].as_array().unwrap().iter().any(|c| c["id"] == "app.palette"), "{r}");
+ // Unknown items with shortcuts and unreadable keys are reported; Save keeps its shortcut.
+ assert_eq!(r["unmatched"], json!([{"item": "Edit Menu › Frobnicate Everything", "keys": "Default: Ctrl+F12"}]));
+ assert_eq!(r["unmatchedWithoutShortcut"], 1);
+ assert_eq!(r["unreadable"][0]["item"], "File Menu › Save");
+ assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S"));
+ // Two items want the same keys: the later one takes them.
+ assert_eq!(menus::shortcut_of(&app, "app.saveDialog").as_deref(), Some("Cmd+Shift+F9"));
+ assert_eq!(r["skipped"][0]["takenBy"], "app.saveDialog", "{r}");
+ assert_eq!(r["imported"], 4);
+ // Reset Shortcuts is the way back.
+ menus::run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap();
+ assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return"));
+ }
+
+ #[test]
+ fn hostile_input_returns_a_report_or_an_error() {
+ let mut app = app();
+ for text in ["", "\0\0\0", "--- ---", " --- Default: Ctrl+", "Product area : \n\t: --- Default: +, Default: ,,,", "x --- Default: ⌘⇧"] {
+ let _ = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": text})).unwrap();
+ }
+ assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({})).unwrap().is_err());
+ assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({"path": "/nonexistent/set.txt"})).unwrap().is_err());
+ // UTF-16 with a BOM, and Mac modifier spellings.
+ let mut bytes = vec![0xFF, 0xFE];
+ bytes.extend("Shortcut Set : Mac\nProduct area : File Menu\n\tClose --- Default: ⇧⌘W\n".encode_utf16().flat_map(u16::to_le_bytes));
+ let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": decode(&bytes).unwrap()})).unwrap().unwrap();
+ assert_eq!(r["set"], "Mac");
+ assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+W"));
+ }
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 507358dec..381498fb6 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -85,7 +85,7 @@ layer survive interchange; literal XML and round-trip tests are in
| PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ |
| Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ |
| Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) |
-| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) |
+| Keyboard shortcut sets | `.indk`; Show Set `.txt` | read/write; write | ~ (imports a Show Set `.txt`; own JSON) |
| User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) |
| Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index c7e0678d7..bb7c5eb80 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -417,7 +417,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes, shown as you draw in the layer's colour, simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
-- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
+- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. Import InDesign Set… reads a set saved by InDesign's Show Set (.txt) and reports what it changed and what it could not match; `window.importShortcuts`.
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 2dcf4687a..b66fb585c 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -43,7 +43,7 @@ basics of editing fail for too many users on Windows and Linux.
| Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) |
| IME text input on the canvas | ✓ | ✓ | ~ | not checked by hand with a system IME (#322) |
| Font menu: search, preview, favourites, type-ahead, filters (class, recently added), styles per family, sample text and size, recently used, similar fonts | ✓ | ✓ | ~ | search field focus on macOS (#335, #86); Adobe Fonts (Find More, activated) out of scope |
-| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | |
+| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md; imports an InDesign Show Set `.txt` | ✓ | tool keys are fixed, so a set's Tools items are reported, not applied |
| Quick Apply | ✓ | ✓ | ✓ | |
| Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | |
| Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |
From 9bd85a14b2857113259da4950f30736a1166791a Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:30:03 -0400
Subject: [PATCH 36/43] i18n: complete the French interface (#1347)
* i18n: complete the French interface (#644)
* i18n: French uses gabarit for parent pages; fixes
* i18n: French for the guide commands and the shortcut-set import
---
crates/ui-egui/src/i18n.rs | 174 +++++++++++++++++++++++----
crates/ui-egui/src/i18n/fr.rs | 220 ++++++++++++++++++++++++++++++++++
2 files changed, 371 insertions(+), 23 deletions(-)
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index acf70ff32..fec123a33 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -139,7 +139,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Pages on Top", ["Seiten oben", "Pages en haut", "Páginas en la parte superior", "ページを上に表示", "页面在上"]),
(
"Parents on Top",
- ["Übergeordnete Seiten oben", "Parents en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"],
+ ["Übergeordnete Seiten oben", "Gabarits en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"],
),
("Resize:", ["Größe ändern:", "Redimensionner :", "Cambiar tamaño:", "サイズ変更:", "调整大小:"]),
("Extra Small", ["Sehr klein", "Très petite", "Extra pequeño", "極小", "特小"]),
@@ -150,8 +150,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Jumbo", ["Riesig", "Géante", "Gigante", "最大", "巨大"]),
("Proportional", ["Proportional", "Proportionnel", "Proporcional", "比例", "按比例"]),
("Pages Fixed", ["Seiten fixiert", "Pages fixes", "Páginas fijas", "ページを固定", "页面固定"]),
- ("Parents Fixed", ["Übergeordnete fixiert", "Parents fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]),
- ("Parents", ["Übergeordnete Seiten", "Parents", "Páginas principales", "親ページ", "父页面"]),
+ ("Parents Fixed", ["Übergeordnete fixiert", "Gabarits fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]),
+ ("Parents", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]),
(
"Bulleted & Numbered Lists",
[
@@ -497,7 +497,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("ICF Top", ["ICF oben", "Haut ICF", "Parte superior ICF", "平均字面の上", "平均字面上"]),
("ICF Bottom", ["ICF unten", "Bas ICF", "Parte inferior ICF", "平均字面の下", "平均字面下"]),
- ("Gyoudori:", ["Gyoudori:", "Gyoudori:", "Gyoudori:", "行取り:", "行取:"]),
+ ("Gyoudori:", ["Gyoudori:", "Gyoudori :", "Gyoudori:", "行取り:", "行取:"]),
(
"Use Paragraph Gyoudori",
["Absatz-Gyoudori verwenden", "Utiliser le gyoudori de paragraphe", "Usar gyoudori de párrafo", "段落行取りを使用", "使用段落行取"],
@@ -734,7 +734,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]),
("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]),
("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]),
- ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]),
+ ("Essentials Classic", ["Essentials Classic", "Les indispensables (classique)", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]),
("Split Window", ["Fenster teilen", "Fractionner la fenêtre", "Dividir ventana", "ウィンドウを分割", "拆分窗口"]),
("New Window", ["Neues Fenster", "Nouvelle fenêtre", "Nueva ventana", "新規ウィンドウ", "新建窗口"]),
(
@@ -873,7 +873,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]),
- ("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]),
+ ("Parent Pages", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]),
(
"Apply Parent to Pages",
[
@@ -1388,12 +1388,12 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Apply None (/)", ["Apply None (/)", "Appliquer Aucun (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]),
(
"Default Fill and Stroke",
- ["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"],
+ ["Default Fill and Stroke", "Fond et contour par défaut", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"],
),
- ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]),
+ ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Permuter fond et contour", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]),
(
"Toggle Fill and Stroke",
- ["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"],
+ ["Toggle Fill and Stroke", "Basculer fond/contour", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"],
),
("Reverse the gradient", ["Reverse the gradient", "Inverser le dégradé", "Reverse the gradient", "Reverse the gradient", "反转渐变"]),
("Style source", ["Style source", "Source du style", "Style source", "Style source", "样式源"]),
@@ -2061,7 +2061,13 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
(
"Show/Hide Document Grid",
- ["Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "显示/隐藏文档网格"],
+ [
+ "Show/Hide Document Grid",
+ "Afficher/Masquer la grille du document",
+ "Show/Hide Document Grid",
+ "Show/Hide Document Grid",
+ "显示/隐藏文档网格",
+ ],
),
(
"Show/Hide Frame Edges",
@@ -2713,7 +2719,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Select an object or text to color its fill or stroke.",
[
"Select an object or text to color its fill or stroke.",
- "Select an object or text to color its fill or stroke.",
+ "Sélectionnez un objet ou du texte pour colorer son fond ou son contour.",
"Select an object or text to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
"选择一个对象或文本以设置其填色或描边颜色。",
@@ -2922,7 +2928,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Cap Height", ["Cap Height", "Hauteur de capitale", "Cap Height", "Cap Height", "大写字母高度"]),
("Leading", ["Leading", "Interligne", "Leading", "Leading", "行距"]),
("x Height", ["x Height", "Hauteur d'x", "x Height", "x Height", "x 字高"]),
- ("Em Box Height", ["Em Box Height", "Em Box Height", "Em Box Height", "Em Box Height", "全角字框高度"]),
+ ("Em Box Height", ["Em Box Height", "Hauteur du cadratin", "Em Box Height", "Em Box Height", "全角字框高度"]),
("Fixed", ["Fixed", "Fixe", "Fixed", "Fixed", "固定"]),
("Never (continuous)", ["Never (continuous)", "Jamais (continu)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]),
("Page", ["Page", "Page", "Page", "Page", "页面"]),
@@ -3505,8 +3511,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Ukrainian", ["Ukrainian", "Ukrainien", "Ukrainian", "Ukrainian", "乌克兰语"]),
("Cell Style", ["Cell Style", "Style de cellule", "Cell Style", "Cell Style", "单元格样式"]),
("Table Style", ["Table Style", "Style de tableau", "Table Style", "Table Style", "表样式"]),
- ("Col ←", ["Col ←", "Col ←", "Col ←", "Col ←", "左侧插入列"]),
- ("Col →", ["Col →", "Col →", "Col →", "Col →", "右侧插入列"]),
+ ("Col ←", ["Col ←", "Col. ←", "Col ←", "Col ←", "左侧插入列"]),
+ ("Col →", ["Col →", "Col. →", "Col →", "Col →", "右侧插入列"]),
("Row ↑", ["Row ↑", "Ligne ↑", "Row ↑", "Row ↑", "上方插入行"]),
("Row ↓", ["Row ↓", "Ligne ↓", "Row ↓", "Row ↓", "下方插入行"]),
("Hide Others", ["Hide Others", "Masquer les autres", "Hide Others", "Hide Others", "隐藏其他图层"]),
@@ -3536,7 +3542,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Show Parent Page Overlay",
[
"Überlagerung der übergeordneten Seite anzeigen",
- "Afficher la superposition de la page parent",
+ "Afficher la superposition du gabarit",
"Mostrar superposición de página principal",
"親ページオーバーレイを表示",
"显示父页面叠加",
@@ -3544,7 +3550,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
(
"Controlled by Parent",
- ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"],
+ ["Durch übergeordnete Seite gesteuert", "Contrôlé par le gabarit", "Controlado por principal", "親ページで制御", "由父页面控制"],
),
("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]),
(
@@ -4229,8 +4235,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Bullets and Numbering…",
["Aufzählungszeichen und Nummerierung…", "Puces et numérotation…", "Viñetas y numeración…", "箇条書き記号と番号付け…", "项目符号和编号…"],
),
- ("List:", ["Liste:", "Liste:", "Lista:", "リスト:", "列表:"]),
- ("Mode:", ["Modus:", "Mode:", "Modo:", "モード:", "模式:"]),
+ ("List:", ["Liste:", "Liste :", "Lista:", "リスト:", "列表:"]),
+ ("Mode:", ["Modus:", "Mode :", "Modo:", "モード:", "模式:"]),
(
"Continue from Previous Number",
[
@@ -4717,7 +4723,7 @@ mod tests {
// `fr::TABLE` is looked up first: a key there would hide the shared table's French column.
let both: Vec<_> = catalog.keys().filter(|k| shared.contains(*k)).collect();
assert!(both.is_empty(), "French keys also in the shared table: {both:?}");
- let mut keys = std::collections::HashSet::new();
+ let mut keys = interface_strings();
for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] {
keys.extend(table.iter().map(|(key, _)| *key));
}
@@ -4738,6 +4744,121 @@ mod tests {
assert_eq!(tr("fr", "Group"), "Associer");
}
+ /// Interface strings that read the same in French: names, abbreviations, numbering samples, key
+ /// symbols and words French shares with English.
+ const SAME_IN_FRENCH: &[&str] = &[
+ " OK ",
+ " (auto)",
+ "+Bin",
+ "+LOC",
+ "A, B, C, D...",
+ "Angle",
+ "Articles",
+ "Bunri-Kinshi",
+ "Commits",
+ "Composition",
+ "Compression",
+ "Conditions",
+ "Cyan",
+ "DesignCraft",
+ "Direction",
+ "Distance",
+ "Document",
+ "Document…",
+ "Ellipse",
+ "Extras",
+ "Fractions",
+ "GREP",
+ "Hindi",
+ "Horizontal",
+ "I, II, III, IV...",
+ "Image",
+ "Index",
+ "Interface",
+ "Justification",
+ "Justification…",
+ "Kanji",
+ "Kenten",
+ "Lab",
+ "Ligatures",
+ "Magenta",
+ "Maximum",
+ "Menus",
+ "Menus…",
+ "Minimum",
+ "Mobile",
+ "Normal",
+ "Normal (W)",
+ "Notes",
+ "OK",
+ "OpenType",
+ "Options",
+ "Orange",
+ "Orientation",
+ "PDF/A-2b",
+ "PDF/X-4",
+ "Page",
+ "Pages",
+ "Pathfinder",
+ "Picas",
+ "Portrait",
+ "Position",
+ "Radial",
+ "Rectangle",
+ "Rensuuji",
+ "Rotation",
+ "Ruby",
+ "Ruby…",
+ "Satin",
+ "Saturation",
+ "Script",
+ "Scripts",
+ "Section",
+ "Serif",
+ "Shatai",
+ "Signature",
+ "Simple",
+ "Structure",
+ "Style",
+ "Styles",
+ "Tate-Chu-Yoko",
+ "Triangle",
+ "Version",
+ "Vertical",
+ "Violet",
+ "Warichu",
+ "Web",
+ "a, b, c, d...",
+ "document",
+ "i, ii, iii, iv...",
+ "pages",
+ "ΔLOC",
+ "⇧",
+ "−Bin",
+ "−LOC",
+ "⌃",
+ "⌘",
+ "⌥",
+ ];
+
+ #[test]
+ fn french_translates_every_interface_string() {
+ let keys = interface_strings();
+ // A new English string needs a French entry (or a place on `SAME_IN_FRENCH`) before it ships.
+ let mut untranslated: Vec<_> = keys.iter().filter(|key| tr("fr", key) == **key && !SAME_IN_FRENCH.contains(key)).collect();
+ untranslated.sort();
+ assert!(untranslated.is_empty(), "French falls back to English for: {untranslated:?}");
+ for key in SAME_IN_FRENCH {
+ assert!(keys.contains(key), "SAME_IN_FRENCH lists a string the interface no longer has: {key}");
+ assert_eq!(tr("fr", key), *key, "SAME_IN_FRENCH lists a string French translates: {key}");
+ }
+ for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) {
+ assert!(tr("fr", tool.label).starts_with("Outil "), "{}", tool.id);
+ }
+ assert_eq!(tr("fr", "Selection Tool"), "Outil Sélection");
+ assert_eq!(tr("fr", "Don't Save"), "Ne pas enregistrer");
+ }
+
#[test]
fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() {
let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect();
@@ -4937,10 +5058,9 @@ mod tests {
assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}");
}
- #[test]
- fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() {
- let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys");
+ /// Every English string the interface passes to `tr`, as far as the catalogs, command and tool
+ /// tables and the list of UI-only labels know them.
+ fn interface_strings() -> std::collections::HashSet<&'static str> {
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
for table in [ar::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE] {
@@ -5033,6 +5153,14 @@ mod tests {
"{name}: import cancelled while a dialog is open; choose the file again",
"{name}: {count} step(s) done",
]);
+ keys
+ }
+
+ #[test]
+ fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() {
+ let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect();
+ assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys");
+ let keys = interface_strings();
// A new English string falls back to English until it is translated, so only stale
// entries (keys nothing passes to `tr` any more) fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs
index 78c5d5948..681230d84 100644
--- a/crates/ui-egui/src/i18n/fr.rs
+++ b/crates/ui-egui/src/i18n/fr.rs
@@ -1,57 +1,118 @@
//! French translations of the strings the shared table in `i18n.rs` has no row for,
//! written from the English source using ordinary French desktop-publishing vocabulary.
pub(super) const TABLE: &[(&str, &str)] = &[
+ ("% of all commits", "% de tous les commits"),
+ ("(any)", "(quelconque)"),
+ ("(unchanged)", "(inchangé)"),
("90° CCW", "90° sens antihoraire"),
("90° CW", "90° sens horaire"),
+ ("About", "À propos"),
+ ("Activate Panel", "Activer le panneau"),
+ ("Add Anchor Point Tool", "Outil Ajout de point d'ancrage"),
+ ("Add Tab", "Ajouter une tabulation"),
+ ("Align To", "Aligner sur"),
("Alignment", "Alignement"),
("All Documents", "Tous les documents"),
("Alpha Channel", "Couche alpha"),
+ ("Alternate layout", "Variante de mise en page"),
("Anchored Object Options", "Options d'objet ancré"),
+ ("Apply Numbering & Section Options", "Appliquer les options de numérotation et de section"),
+ ("Apply Text Attributes (Eyedropper)", "Appliquer les attributs de texte (Pipette)"),
("ArtCraft App Page", "Page de l'application ArtCraft"),
("ArtCraft Website", "Site web d'ArtCraft"),
("Auto Size", "Taille automatique"),
+ ("Auto Tate-Chu-Yoko", "Tate-Chu-Yoko automatique"),
+ ("Binary assets added", "Fichiers binaires ajoutés"),
+ ("Binary assets removed", "Fichiers binaires supprimés"),
("Binding", "Reliure"),
+ ("Break Thread", "Rompre le chaînage"),
("Bullet Character", "Caractère de puce"),
("CMYK:", "CMJN :"),
+ ("Cancel Preview", "Annuler l'aperçu"),
+ ("Cell Number:", "Numéro de mobile :"),
+ ("Change Panel Layout", "Modifier la disposition des panneaux"),
+ ("Change Tab", "Modifier la tabulation"),
("Change To", "Remplacer par"),
+ ("Change fill to:", "Remplacer le fond par :"),
+ ("Character Style", "Style de caractère"),
("Check Spelling", "Vérifier l'orthographe"),
+ ("Chinese: Simplified", "Chinois : simplifié"),
+ ("Chinese: Traditional", "Chinois : traditionnel"),
+ ("Clear All Tabs", "Effacer toutes les tabulations"),
+ ("Clear Object Style Overrides", "Effacer les remplacements du style d'objet"),
("Clear Rotation", "Effacer la rotation"),
+ ("Clipping Path Options…", "Options de masque…"),
+ ("Close Panel", "Fermer le panneau"),
+ ("Close Window", "Fermer la fenêtre"),
("Closed Path", "Tracé fermé"),
("Color Blindness – Deuteranopia", "Daltonisme – Deutéranopie"),
("Color Blindness – Protanopia", "Daltonisme – Protanopie"),
+ ("Color Theme Tool", "Outil Thème de couleur"),
+ ("Color Theme…", "Thème de couleur…"),
("Colour", "Couleur"),
("Colour Picker", "Sélecteur de couleurs"),
("Column Break", "Saut de colonne"),
+ ("Commit Preview", "Valider l'aperçu"),
("Community", "Communauté"),
+ ("Company", "Entreprise"),
("Conditional Text", "Texte conditionnel"),
+ ("Content Collector Tool", "Outil Collecteur de contenu"),
+ ("Content Placer Tool", "Outil Placeur de contenu"),
+ ("Contributors", "Contributeurs"),
+ ("Convert Direction Point Tool", "Outil Conversion de point directeur"),
+ ("Convert Point", "Convertir le point"),
+ ("Convert Rows", "Convertir les lignes"),
("Copyright Symbol", "Symbole du copyright"),
("Corner Options", "Options d'arrondi"),
+ ("Couldn't write recovery data: {error}", "Impossible d'écrire les données de récupération : {error}"),
("Create Guides", "Créer des repères"),
("Dark", "Foncé"),
+ ("Data Fields", "Champs de données"),
+ ("Data Merge Options", "Options de fusion des données"),
("Define Cross-Reference Format", "Définir un format de référence croisée"),
("Define Lists", "Définir des listes"),
("Define Text Variable", "Définir une variable de texte"),
+ ("Delete Anchor Point Tool", "Outil Suppression de point d'ancrage"),
+ ("Delete Character Style", "Supprimer le style de caractère"),
+ ("Delete Document Preset", "Supprimer le paramètre prédéfini de document"),
+ ("Delete Parent", "Supprimer le gabarit"),
("Delete Type from Path", "Supprimer le texte curviligne"),
+ ("Deselect Guides", "Désélectionner les repères"),
("DesignCraft Website", "Site web de DesignCraft"),
("DesignCraft on GitHub", "DesignCraft sur GitHub"),
+ ("DesignCraft page", "Page DesignCraft"),
("Detect Edges", "Détecter les contours"),
+ ("Direct Selection Tool", "Outil Sélection directe"),
("Discretionary Hyphen", "Trait d'union conditionnel"),
+ ("Display name", "Nom affiché"),
+ ("Distribute Rows Evenly", "Répartir les lignes uniformément"),
("Document CMYK", "CMJN du document"),
("Document Endnote Options", "Options de notes de fin de document"),
("Document Footnote Options", "Options de notes de bas de page du document"),
("Document RGB", "RVB du document"),
+ ("Don't Save", "Ne pas enregistrer"),
("Done", "Terminé"),
("Double Left Quotation Marks", "Guillemets doubles ouvrants"),
("Double Right Quotation Marks", "Guillemets doubles fermants"),
+ ("Drag Rows or Columns", "Faire glisser des lignes ou des colonnes"),
("Edit/Interface Language", "Édition/Langue de l'interface"),
("Edit/Spelling", "Édition/Orthographe"),
("Edit/Transparency Blend Space", "Édition/Espace de fusion de la transparence"),
("Edit/Transparency Flattener Presets", "Édition/Préréglages d'aplatissement de la transparence"),
+ ("Ellipse Frame Tool", "Outil Bloc elliptique"),
+ ("Ellipse Tool", "Outil Ellipse"),
("Ellipsis", "Points de suspension"),
("Em Dash", "Tiret cadratin"),
("Em Space", "Espace cadratin"),
+ ("Email Address:", "Adresse e-mail :"),
+ ("Email:", "E-mail :"),
("En Dash", "Tiret demi-cadratin"),
("En Space", "Espace demi-cadratin"),
+ (
+ "Equal fixed heights for the selected rows; cell text may become overset.",
+ "Hauteurs fixes identiques pour les lignes sélectionnées ; le texte des cellules peut déborder.",
+ ),
+ ("Erase Tool", "Outil Gomme"),
("Export EPUB", "Exporter en EPUB"),
("Export EPUB (Fixed Layout)", "Exporter en EPUB (mise en page fixe)"),
("Export EPUB (Reflowable)", "Exporter en EPUB (redistribuable)"),
@@ -59,50 +120,99 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Export IDML", "Exporter en IDML"),
("Export PDF (Interactive)", "Exporter en PDF (interactif)"),
("Export Page as PNG", "Exporter la page en PNG"),
+ ("Export PowerPoint…", "Exporter en PowerPoint…"),
("Export Text", "Exporter le texte"),
("Export XML", "Exporter en XML"),
+ (
+ "Export a PDF, IDML or package to share — no cloud account needed.",
+ "Exportez un PDF, un fichier IDML ou un assemblage à partager — aucun compte cloud requis.",
+ ),
+ ("Extract Color Theme", "Extraire le thème de couleur"),
+ ("Eyedropper Tool", "Outil Pipette"),
("Figure Space", "Espace chiffre"),
("File/Export", "Fichier/Exporter"),
("Find What", "Rechercher"),
+ ("Find fill:", "Rechercher le fond :"),
+ ("First commit", "Premier commit"),
("Flush Space", "Espace sans alinéa"),
("Forced Line Break", "Saut de ligne forcé"),
+ ("Formatting Affects Text", "La mise en forme agit sur le texte"),
+ ("Found", "Trouvé"),
("Frame Break", "Saut de bloc"),
+ ("Free Transform Tool", "Outil Transformation manuelle"),
("GREP", "GREP"),
+ ("Gap Tool", "Outil Espace"),
("Generate Index", "Générer l'index"),
("Generate Live Caption", "Générer une légende dynamique"),
+ ("Get Cell Stroke", "Obtenir le contour de cellule"),
+ ("Get Tabs", "Obtenir les tabulations"),
("Global Light", "Lumière globale"),
+ ("Grab bag", "Liste"),
+ ("Gradient Feather Tool", "Outil Contour progressif dégradé"),
+ ("Gradient Swatch Tool", "Outil Dégradé de nuance"),
("Graphic", "Graphique"),
+ ("Group with", "Grouper avec"),
("Hair Space", "Espace ultrafine"),
+ ("Hand Tool", "Outil Main"),
("Hide Guides", "Masquer les repères"),
("High Contrast", "Contraste élevé"),
("High Resolution", "Haute résolution"),
("I, II, III, IV...", "I, II, III, IV..."),
+ ("Import InDesign Shortcut Set", "Importer un jeu de raccourcis InDesign"),
("Import XML", "Importer XML"),
+ ("Import cancelled: the originating document has closed", "Importation annulée : le document d'origine a été fermé"),
("Indent", "Retrait"),
("Indent to Here", "Retrait jusqu'à ce point"),
("Ink Limit 300%", "Limite d'encre 300 %"),
("Insert Anchored Object", "Insérer un objet ancré"),
("Insert Cross-Reference", "Insérer une référence croisée"),
+ ("Insert Field", "Insérer un champ"),
("Insert Math Expression", "Insérer une expression mathématique"),
("Insert Pages", "Insérer des pages"),
("Internet Standard RGB (sRGB)", "RVB standard Internet (sRGB)"),
+ ("Invalid panel layout; restored the default dock", "Disposition des panneaux non valide ; disposition par défaut restaurée"),
+ ("Join", "Joindre"),
("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"),
("Join the community", "Rejoindre la communauté"),
("Kerning", "Crénage"),
+ ("Kerning/Tracking Step", "Pas de crénage/d'approche"),
+ ("Key Object", "Objet clé"),
+ ("Last commit", "Dernier commit"),
("Layout/Index", "Page/Index"),
("Layout/Pages", "Page/Pages"),
("Legacy Macintosh RGB", "RVB Macintosh hérité"),
("Light", "Clair"),
+ ("Line Tool", "Outil Trait"),
+ ("Line delta", "Différence de lignes"),
+ ("Lines +/−", "Lignes +/−"),
+ ("Lines added", "Lignes ajoutées"),
+ ("Lines deleted", "Lignes supprimées"),
+ ("List Composite Fonts", "Lister les polices composites"),
+ ("List Styles in File", "Lister les styles du fichier"),
("Load DTD", "Charger la DTD"),
+ ("Load Styles…", "Charger les styles…"),
("Load Swatches", "Charger des nuances"),
("Low Resolution", "Basse résolution"),
("Margins:", "Marges :"),
+ ("Maximize/Restore Window", "Agrandir/Restaurer la fenêtre"),
+ ("Measure Tool", "Outil Mesure"),
("Medium Dark", "Moyennement foncé"),
("Medium Light", "Moyennement clair"),
("Medium Resolution", "Résolution moyenne"),
("Menus", "Menus"),
+ ("Merged PRs", "PR fusionnées"),
+ ("Message:", "Message :"),
+ ("Minimize Window", "Réduire la fenêtre"),
+ ("Models", "Modèles"),
("Monitor RGB", "RVB du moniteur"),
+ ("Move Column Guide", "Déplacer le repère de colonne"),
+ ("Move Guides", "Déplacer les repères"),
("Move Pages", "Déplacer des pages"),
+ ("Move Panel", "Déplacer le panneau"),
+ ("Move Tab", "Déplacer la tabulation"),
+ ("Move Window", "Déplacer la fenêtre"),
+ ("Name (A–Z)", "Nom (A–Z)"),
+ ("New Cell Style", "Nouveau style de cellule"),
("New Condition", "Nouvelle condition"),
("New Hyperlink", "Nouvel hyperlien"),
("New Hyperlink Destination", "Nouvelle destination d'hyperlien"),
@@ -110,13 +220,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Page Reference", "Nouvelle référence de page"),
("New Parent", "Nouveau gabarit"),
("New Stroke Style", "Nouveau style de contour"),
+ ("New Table Style", "Nouveau style de tableau"),
("Next Page Number", "Numéro de la page suivante"),
+ ("No contributor data was built into this copy.", "Aucune donnée sur les contributeurs n'est intégrée à cette copie."),
+ ("No matches", "Aucune correspondance"),
+ ("No model credits were built into this copy.", "Aucun crédit de modèle n'est intégré à cette copie."),
("Nonbreaking Hyphen", "Trait d'union insécable"),
("Nonbreaking Space", "Espace insécable"),
("None (keep transparency)", "Aucun (conserver la transparence)"),
+ ("Note Tool", "Outil Note"),
("Number of Pages", "Nombre de pages"),
("Numbering & Section Options", "Options de numérotation et de section"),
("OK", "OK"),
+ ("Object Style", "Style d'objet"),
+ ("Object Style Options…", "Options de style d'objet…"),
("Object Styles", "Styles d'objet"),
("Object/Anchored Object", "Objet/Objet ancré"),
("Object/Arrange", "Objet/Disposition"),
@@ -132,64 +249,130 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Object/Transform", "Objet/Transformation"),
("Object/Transform Again", "Objet/Répéter la transformation"),
("On", "Activé"),
+ ("Organization:", "Organisation :"),
("Orientation", "Orientation"),
+ ("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"),
+ ("PRs", "PR"),
("Package", "Assemblage"),
+ ("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"),
("Page Break", "Saut de page"),
+ ("Page Options", "Options de page"),
+ ("Page Tool", "Outil Page"),
("Panels", "Panneaux"),
("Paragraph Return", "Retour de paragraphe"),
+ ("Paragraph Style", "Style de paragraphe"),
("Paragraph Symbol", "Symbole de paragraphe"),
+ ("Parent Options…", "Options de gabarit…"),
+ ("Pen Tool", "Outil Plume"),
+ ("Pencil Tool", "Outil Crayon"),
+ ("Phone:", "Téléphone :"),
("Picas", "Picas"),
+ ("Pin Bar Position", "Épingler la position de la barre"),
("Place with Import Options", "Importer avec les options d'importation"),
+ ("Polygon Frame Tool", "Outil Bloc polygonal"),
+ ("Polygon Tool", "Outil Polygone"),
("Preflight", "Contrôle en amont"),
("Preflight Panel…", "Panneau Contrôle en amont…"),
+ ("Press keys…", "Appuyez sur des touches…"),
+ ("Preview Change", "Prévisualiser la modification"),
+ ("Preview Record", "Aperçu de l'enregistrement"),
("Previous Page Number", "Numéro de la page précédente"),
("Print Booklet", "Imprimer le cahier"),
("Punctuation Space", "Espace de ponctuation"),
("Quarter Space", "Quart d'espace"),
("Quick Apply", "Application rapide"),
("RGB:", "RVB :"),
+ ("Range:", "Plage :"),
+ ("Real name", "Nom réel"),
+ ("Recent Files", "Fichiers récents"),
+ ("Rectangle Frame Tool", "Outil Bloc rectangulaire"),
+ ("Rectangle Tool", "Outil Rectangle"),
+ ("Redefine Character Style", "Redéfinir le style de caractère"),
+ ("Redefine Paragraph Style", "Redéfinir le style de paragraphe"),
("Registered Trademark Symbol", "Symbole de marque déposée"),
("Relink", "Rééditer le lien"),
("Relink to Folder", "Rééditer le lien au dossier"),
+ ("Relinked {count}", "Liens rétablis : {count}"),
+ ("Reload Fonts", "Recharger les polices"),
+ ("Remove Data Source", "Supprimer la source de données"),
+ ("Remove Field", "Supprimer le champ"),
+ ("Remove Frame from Thread", "Retirer le bloc du chaînage"),
+ ("Reorder Objects", "Réorganiser les objets"),
("Report an Issue", "Signaler un problème"),
+ ("Report an issue", "Signaler un problème"),
+ ("Reset Bar Position", "Réinitialiser la position de la barre"),
+ ("Resize Row or Column", "Redimensionner la ligne ou la colonne"),
+ ("Resize Window", "Redimensionner la fenêtre"),
+ ("Resize panel window", "Redimensionner la fenêtre du panneau"),
+ ("Resize panels", "Redimensionner les panneaux"),
+ ("Reverse the order", "Inverser l'ordre"),
("Right Indent Tab", "Tabulation de retrait à droite"),
+ ("Rotate Tool", "Outil Rotation"),
("Rotation", "Rotation"),
+ ("Ruler Zero Point", "Point zéro des règles"),
("Save As", "Enregistrer sous"),
("Save Swatches for Exchange", "Enregistrer les nuances pour échange"),
("Save a Copy", "Enregistrer une copie"),
+ ("Scale Tool", "Outil Mise à l'échelle"),
+ ("Scissors Tool", "Outil Ciseaux"),
("Scripts", "Scripts"),
("Section Marker", "Marque de section"),
("Section Symbol", "Symbole de section"),
+ ("Select Guide", "Sélectionner le repère"),
+ ("Select Page", "Sélectionner la page"),
+ ("Select Paragraph", "Sélectionner le paragraphe"),
+ ("Select Sentence", "Sélectionner la phrase"),
+ ("Select Story", "Sélectionner l'article"),
+ ("Select Text Unit", "Sélectionner une unité de texte"),
("Selection", "Sélection"),
+ ("Selection Tool", "Outil Sélection"),
+ ("Sent {count} page(s) to {printer}", "{count} page(s) envoyée(s) à {printer}"),
("Sentence case", "Majuscule en début de phrase"),
+ ("Set Composite Font", "Définir la police composite"),
+ ("Set OpenType Features", "Définir les fonctions OpenType"),
+ ("Shear Tool", "Outil Déformation"),
("Simulate Paper Color", "Simuler la couleur du papier"),
("Single Left Quotation Mark", "Guillemet simple ouvrant"),
("Single Right Quotation Mark", "Guillemet simple fermant"),
("Sixth Space", "Sixième d'espace"),
("Smart Guide Options", "Options des repères commentés"),
+ ("Smooth Tool", "Outil Lissage"),
("Space After", "Espace après"),
("Space Before", "Espace avant"),
("Start Page #", "N° de la première page"),
("Step and Repeat", "Exécuter et répéter"),
+ ("Stop Preview", "Arrêter l'aperçu"),
("Story Column Direction", "Sens des colonnes de l'article"),
+ ("Story Editor — {count} words", "Mode éditeur — {count} mots"),
+ ("Story {story} ← story {parent}", "Article {story} ← article {parent}"),
("Straight Double Quotation Marks", "Guillemets droits doubles"),
("Straight Single Quotation Mark (Apostrophe)", "Guillemet droit simple (apostrophe)"),
+ ("Subject:", "Objet :"),
("Subscript:", "Indice :"),
("Superscript:", "Exposant :"),
+ ("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"),
("Tab", "Tabulation"),
("Table of Contents", "Table des matières"),
("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"),
("Table/Delete", "Tableau/Supprimer"),
("Table/Insert", "Tableau/Insérer"),
("Table/Select", "Tableau/Sélectionner"),
+ ("Text Anchor", "Ancre de texte"),
+ ("Text:", "Texte :"),
("Thin Space", "Espace fine"),
("Third Space", "Tiers d'espace"),
+ ("Thread Text Frames", "Chaîner les blocs de texte"),
("Title Case", "Majuscule à chaque mot"),
+ ("To Body", "En lignes de corps"),
+ ("To Footer", "En lignes de pied"),
+ ("To Header", "En lignes d'en-tête"),
("Trademark Symbol", "Symbole de marque"),
("Transform Again Individually", "Répéter la transformation individuellement"),
("Transform Sequence Again", "Répéter la séquence de transformations"),
("Transform Sequence Again Individually", "Répéter la séquence de transformations individuellement"),
+ ("Type Tool", "Outil Texte"),
("Type on a Path Options", "Options de texte curviligne"),
+ ("Type on a Path Tool", "Outil Texte curviligne"),
("Type/Bulleted and Numbered Lists", "Texte/Listes à puces et numérotées"),
("Type/Change Case", "Texte/Modifier la casse"),
("Type/Hyperlinks & Cross-References", "Texte/Hyperliens et références croisées"),
@@ -210,8 +393,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("URL:", "URL :"),
("Unassigned", "Non attribué"),
("Units", "Unités"),
+ ("Unsaved Changes", "Modifications non enregistrées"),
+ ("Update Data Source", "Mettre à jour la source de données"),
+ ("Username", "Nom d'utilisateur"),
+ ("Validate from Root Element", "Valider à partir de l'élément racine"),
+ ("Vertical Type Tool", "Outil Texte vertical"),
+ ("Vertical Type on a Path Tool", "Outil Texte curviligne vertical"),
("View/Display Performance", "Affichage/Performances d'affichage"),
+ ("View/Extras", "Affichage/Extras"),
("View/Grids & Guides", "Affichage/Grilles et repères"),
+ ("View/Proof Setup", "Affichage/Format d'épreuve"),
+ ("View/Rotate Spread", "Affichage/Faire pivoter la planche"),
+ ("View/Screen Mode", "Affichage/Mode de l'écran"),
+ ("View/Separations Preview", "Affichage/Aperçu des séparations"),
+ ("View/Structure", "Affichage/Structure"),
("Window/Arrange", "Fenêtre/Disposition"),
("Window/Interactive", "Fenêtre/Interactif"),
("Window/Interface Color Theme", "Fenêtre/Thème de couleur de l'interface"),
@@ -220,6 +415,31 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Window/Type & Tables", "Fenêtre/Texte et tableaux"),
("Window/Utilities", "Fenêtre/Utilitaires"),
("Working CMYK", "CMJN de travail"),
+ ("Zoom Tool", "Outil Zoom"),
+ ("[No Language]", "[Aucune langue]"),
("i, ii, iii, iv...", "i, ii, iii, iv..."),
+ ("image editing", "retouche d'images"),
+ ("layer", "calque"),
+ ("layers", "calques"),
("lowercase", "minuscules"),
+ ("motion graphics and visual effects", "animation graphique et effets visuels"),
+ ("photo library and raw development", "photothèque et développement raw"),
+ ("reading, organizing and protecting PDFs", "lecture, organisation et protection de PDF"),
+ ("the default printer", "l'imprimante par défaut"),
+ ("vector illustration", "illustration vectorielle"),
+ ("video editing, color and sound", "montage vidéo, couleur et son"),
+ ("{contributors} contributors · {commits} commits", "{contributors} contributeurs · {commits} commits"),
+ ("{count} conflict(s): {names}", "{count} conflit(s) : {names}"),
+ ("{count} match(es)", "{count} correspondance(s)"),
+ ("{count} replacement(s) made", "{count} remplacement(s) effectué(s)"),
+ ("{label}: {name} (double-click: Color Picker)", "{label} : {name} (double-clic : Sélecteur de couleurs)"),
+ (
+ "{name}: import cancelled while a dialog is open; choose the file again",
+ "{name} : importation annulée car une boîte de dialogue était ouverte ; choisissez à nouveau le fichier",
+ ),
+ ("{name}: {count} step(s) done", "{name} : {count} étape(s) effectuée(s)"),
+ (
+ "“{}” has changes that are not saved. Save them before closing?",
+ "« {} » contient des modifications non enregistrées. Les enregistrer avant de fermer ?",
+ ),
];
From e44662305f2367189e0fd11bac9d010e37e3f73b Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Thu, 22 Oct 2026 10:25:46 -0400
Subject: [PATCH 37/43] i18n tests: the same checks for every language, none
fail on a missing translation (#1576)
* i18n: missing French strings don't fail tests
* i18n: drop the Czech tool-name translation check
* i18n tests: one catalog check for every language, no translated literals
One test checks every catalog (each language's table and context table, and
the shared tables' columns) for duplicate and stale keys, empty translations,
placeholders, padding, ellipses and label colons. One test selects, persists
and checks document data for every interface language. Context lookup,
fallback, built-in names and RTL are checked over every language against the
catalogs. Asserts on particular translated text, and checks that particular
strings are translated, are gone.
* i18n: drop a stale German entry
---
crates/ui-egui/src/chrome.rs | 16 +-
crates/ui-egui/src/dialogs.rs | 2 +-
crates/ui-egui/src/guide_selection_tests.rs | 5 -
crates/ui-egui/src/i18n.rs | 652 +++++---------------
crates/ui-egui/src/i18n/de.rs | 1 -
crates/ui-egui/src/lib.rs | 2 +-
crates/ui-egui/src/menus.rs | 167 +----
crates/ui-egui/src/panels/properties.rs | 15 +-
8 files changed, 205 insertions(+), 655 deletions(-)
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 0df5361f9..e853e6793 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -1155,7 +1155,7 @@ mod tests {
/// The application bar's search field (German interface).
fn search_node<'h>(h: &'h egui_kittest::Harness<'static, test_window::Window>) -> egui_kittest::Node<'h> {
- h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some("Suchen"))
+ h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some(crate::i18n::tr("de", "Search")))
}
/// A document with two frames, the Selection tool, German menus, and the search field clicked.
@@ -1187,12 +1187,12 @@ mod tests {
#[test]
fn the_search_field_finds_and_runs_menu_commands() {
let mut h = searching();
- h.event(egui::Event::Text("gruppier".into()));
+ let group = crate::i18n::tr("de", "Group");
+ h.event(egui::Event::Text(group.to_lowercase()));
h.run_steps(4);
- assert!(row(&h, "Gruppieren").is_some(), "the German label is listed");
- assert!(row(&h, "Gruppierung aufheben").is_some());
+ assert!(row(&h, group).is_some(), "the German label is listed");
// Nothing is selected, so Group is off and Return doesn't run it.
- assert!(row(&h, "Gruppieren").is_some_and(|n| n.accesskit_node().is_disabled()));
+ assert!(row(&h, group).is_some_and(|n| n.accesskit_node().is_disabled()));
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(items(&h), (2, 0), "a disabled command doesn't run");
@@ -1205,7 +1205,7 @@ mod tests {
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(items(&h).0, 1, "the two frames are grouped");
- assert!(row(&h, "Gruppieren").is_none(), "the matches closed");
+ assert!(row(&h, group).is_none(), "the matches closed");
assert!(!h.ctx.memory(|m| m.has_focus(super::search_id())), "the field gave the keyboard back");
}
@@ -1227,11 +1227,11 @@ mod tests {
assert_eq!(items(&h), (2, 2), "nothing was deleted or deselected");
h.event(egui::Event::Text("zz-no-such-command".into()));
h.run_steps(4);
- assert!(h.query_by_label("Keine passenden Befehle").is_some());
+ assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_some());
// Escape closes the matches and clears the field without touching the document.
h.key_press(egui::Key::Escape);
h.run_steps(4);
- assert!(h.query_by_label("Keine passenden Befehle").is_none());
+ assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_none());
assert!(!h.ctx.text_edit_focused());
assert_eq!(h.state().app.session.tool_id(), "rectangleFrame");
assert_eq!(items(&h), (2, 2));
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 6b60b1296..40787954f 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -8963,7 +8963,7 @@ mod tests {
assert_eq!(app.ui.language, "de");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
- let heading = visible_label(&labels, screen, "Sprache der Oberfläche");
+ let heading = visible_label(&labels, screen, crate::i18n::tr("de", "Interface Language"));
let combo = visible_label(&labels, screen, "Deutsch");
// The names stay endonyms in the German interface.
diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs
index 6c41d0122..d69026712 100644
--- a/crates/ui-egui/src/guide_selection_tests.rs
+++ b/crates/ui-egui/src/guide_selection_tests.rs
@@ -190,11 +190,6 @@ fn select_all_guides_is_in_the_edit_menu_on_the_spread_in_view() {
assert_eq!(crate::menus::default_shortcut("guide.selectAll"), Some("Cmd+Alt+G"));
crate::menus::activate(h.state_mut(), "guide.selectAll", &serde_json::Value::Null);
assert_eq!(selected(&h), [0, 1, 2]);
- assert_eq!(crate::i18n::tr("de", "Select All Guides"), "Alle Hilfslinien auswählen");
- for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
- assert_ne!(crate::i18n::tr(code, "Select All Guides"), "Select All Guides", "{code}");
- assert_ne!(crate::i18n::tr(code, "Ruler Guide"), "Ruler Guide", "{code}");
- }
h.state_mut().ui.guides = false;
assert!(!crate::menus::menu_enabled(h.state(), "guide.selectAll"));
}
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index fec123a33..6057abc97 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -4598,332 +4598,198 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str {
#[cfg(test)]
mod tests {
use super::*;
+ use std::collections::HashSet;
+
+ /// Every per-language table `tr` looks up, by interface language.
+ const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[
+ ("ar", ar::TABLE),
+ ("cs", cs::TABLE),
+ ("de", de::TABLE),
+ ("fr", fr::TABLE),
+ ("it", it::TABLE),
+ ("ja", ja::TABLE),
+ ("ka", ka::TABLE),
+ ("pl", pl::TABLE),
+ ("pt-br", pt_br::TABLE),
+ ("ru", ru::TABLE),
+ ("uk", uk::TABLE),
+ ];
+
+ /// Every per-language table `tr_context` looks up.
+ const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] =
+ &[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)];
+
+ /// The languages of the shared tables' columns, in column order.
+ const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"];
+
+ /// The German and Japanese tables translate the whole interface and win over the shared columns.
+ const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"];
+
+ #[test]
+ fn every_interface_language_has_a_catalog() {
+ for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
+ assert_eq!(column(lang), Some(i), "{lang}");
+ }
+ for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) {
+ assert!(!name.is_empty(), "{code}");
+ assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES");
+ }
+ }
#[test]
fn cached_lookup_preserves_every_translation_across_language_switches() {
+ let own = |lang: &str, key: &str| {
+ LANGUAGE_TABLES.iter().find(|(code, _)| *code == lang).and_then(|(_, table)| table.iter().find(|(k, _)| *k == key)).map(|(_, text)| *text)
+ };
for (key, translations) in TABLE {
- for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) {
- // `de::TABLE` and `ja::TABLE` translate the whole interface and win over the shared columns.
- if lang != "de" && lang != "ja" {
- assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
- }
+ for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) {
+ // A language's own table is looked up before the shared column.
+ assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}");
}
assert_eq!(tr("", key), *key);
}
- for (key, expected) in de::TABLE {
- assert_eq!(tr("de", key), *expected, "de: {key}");
- }
- for (key, expected) in ar::TABLE {
- assert_eq!(tr("ar", key), *expected, "ar: {key}");
- }
- for (key, expected) in pt_br::TABLE {
- assert_eq!(tr("pt-br", key), *expected, "pt-br: {key}");
- }
- for (key, expected) in it::TABLE {
- assert_eq!(tr("it", key), *expected, "it: {key}");
- }
- for (key, expected) in uk::TABLE {
- assert_eq!(tr("uk", key), *expected, "uk: {key}");
- }
- for (key, expected) in ru::TABLE {
- assert_eq!(tr("ru", key), *expected, "ru: {key}");
- }
- for (key, expected) in cs::TABLE {
- assert_eq!(tr("cs", key), *expected, "cs: {key}");
- }
- for (key, expected) in fr::TABLE {
- assert_eq!(tr("fr", key), *expected, "fr: {key}");
- }
- for (key, expected) in pl::TABLE {
- assert_eq!(tr("pl", key), *expected, "pl: {key}");
- }
- for (key, expected) in ka::TABLE {
- assert_eq!(tr("ka", key), *expected, "ka: {key}");
+ for (lang, table) in LANGUAGE_TABLES {
+ for (key, expected) in *table {
+ assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
+ assert_eq!(tr("", key), *key);
+ }
}
+ // Strings no catalog has stay as they are, in every language.
let unknown = String::from("A user-defined untranslated label");
- for lang in ["pl", "cs", "uk", "ru", "ar", "pt-br", "it", "ka", "zh", "ja", "de", "fr", "es", "", "unknown"] {
- assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()));
+ for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en", "unknown"]) {
+ assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()), "{lang}");
+ // Fixed standard names stay identical.
+ assert_eq!(tr(lang, "PDF/X-4"), "PDF/X-4", "{lang}");
}
}
#[test]
- fn translates_known_strings_and_keeps_the_rest() {
- assert_eq!(tr("de", "File"), "Datei");
- assert_eq!(tr("ja", "Swatches"), "スウォッチ");
- assert_eq!(tr("fr", "Something new"), "Something new");
- assert_eq!(tr("", "File"), "File");
- assert_eq!(tr("ar", "File"), "ملف");
- assert_eq!(tr("ar", "Unknown label"), "Unknown label");
- assert_eq!(tr("pt-br", "File"), "Arquivo");
- assert_eq!(tr("pt-br", "New Document…"), "Novo documento…");
- assert_eq!(tr("pt-br", "Unknown label"), "Unknown label");
- assert_eq!(tr("it", "Edit"), "Modifica");
- assert_eq!(tr("it", "New Document…"), "Nuovo documento…");
- assert_eq!(tr("it", "Unknown label"), "Unknown label");
- assert!(is_rtl("ar"));
- for (i, (en, translation)) in ar::TABLE.iter().enumerate() {
- assert!(ar::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
- assert!(!translation.is_empty(), "{en}");
+ fn context_captions_come_from_their_context_or_the_plain_translation() {
+ for (lang, table) in CONTEXT_TABLES {
+ for (key, scope, text) in *table {
+ assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})");
+ }
}
- for (i, (en, translation)) in pt_br::TABLE.iter().enumerate() {
- assert!(pt_br::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
- assert!(!translation.is_empty(), "{en}");
+ for (key, scope, row) in CONTEXT {
+ for (lang, shared) in SHARED_COLUMNS.into_iter().zip(row) {
+ // A language's own context table is looked up before the shared one.
+ let own = CONTEXT_TABLES
+ .iter()
+ .filter(|(code, _)| *code == lang)
+ .flat_map(|(_, table)| table.iter())
+ .find(|(k, s, _)| k == key && s == scope)
+ .map(|(_, _, text)| *text);
+ assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})");
+ }
}
- for (i, (en, translation)) in it::TABLE.iter().enumerate() {
- assert!(it::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
- assert!(!translation.is_empty(), "{en}");
- }
- // Italian entries are for keys the shared or Arabic table knows. A new English string
- // falls back to English until it is translated, so it doesn't fail here.
- for (en, _) in it::TABLE {
- assert!(TABLE.iter().any(|(key, _)| key == en) || ar::TABLE.iter().any(|(key, _)| key == en), "it: unknown key {en}");
- }
- // Every row is unique and complete.
- for (i, (en, t)) in TABLE.iter().enumerate() {
- assert!(TABLE[..i].iter().all(|(e, _)| e != en), "duplicate {en}");
- assert!(t.iter().all(|x| !x.is_empty()), "{en}");
+ // An unknown context, or a language without one, keeps the plain translation.
+ let keys = CONTEXT.iter().map(|(key, _, _)| *key).chain(CONTEXT_TABLES.iter().flat_map(|(_, table)| table.iter().map(|(key, _, _)| *key)));
+ for key in keys {
+ for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en"]) {
+ assert_eq!(tr_context(lang, key, "no such context"), tr(lang, key), "{lang}: {key}");
+ }
+ assert_eq!(tr_context("", key, "count"), key);
}
}
+ /// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding,
+ /// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back
+ /// to English, so missing translations don't fail here.
#[test]
- fn a_column_count_and_a_list_number_read_differently() {
- for (lang, count, numeral) in [
- ("de", "Anzahl:", "Zahl:"),
- ("ja", "数:", "番号:"),
- ("uk", "Кількість:", "Номер:"),
- ("ar", "العدد:", "الرقم:"),
- ("fr", "Nombre :", "Numéro:"),
- ("zh", "数量:", "编号:"),
- ] {
- assert_eq!((tr_context(lang, "Number:", "count"), tr(lang, "Number:")), (count, numeral), "{lang}");
+ fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() {
+ // (language, key, translation) of every catalog, and every catalog's keys by name.
+ let mut entries: Vec<(&str, &str, &str)> = Vec::new();
+ let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new();
+ for (lang, table) in LANGUAGE_TABLES {
+ let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect();
+ assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys");
+ catalogs.push((*lang, keys));
+ entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text)));
}
- assert_eq!(tr_context("en", "Number:", "count"), "Number:");
- assert_eq!(tr("de", "Gutter:"), "Spaltenabstand:");
- // The Pages panel menu.
- for key in [
- "Insert Pages…",
- "Move Pages…",
- "New Parent…",
- "Override All Parent Page Items",
- "Remove All Local Overrides",
- "Detach All Objects from Parent",
- "Create Alternate Layout…",
- "Allow Selected Spread to Shuffle",
- ] {
- assert_ne!(tr("pt-br", key), key);
+ let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
+ assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
+ catalogs.push(("shared", shared.clone()));
+ for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) {
+ entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text))));
+ entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text))));
+ }
+ let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::>())];
+ for (lang, table) in CONTEXT_TABLES {
+ contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect()));
+ entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text)));
}
- assert_ne!(tr("ar", "Apply Parent to Pages"), "Apply Parent to Pages");
- }
- #[test]
- fn french_catalog_has_no_duplicate_or_stale_keys_and_keeps_placeholders() {
- let catalog: HashMap<_, _> = fr::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), fr::TABLE.len(), "duplicate French keys");
- let shared: std::collections::HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
- // `fr::TABLE` is looked up first: a key there would hide the shared table's French column.
- let both: Vec<_> = catalog.keys().filter(|k| shared.contains(*k)).collect();
- assert!(both.is_empty(), "French keys also in the shared table: {both:?}");
- let mut keys = interface_strings();
- for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] {
- keys.extend(table.iter().map(|(key, _)| *key));
+ // A per-language table is looked up before the shared table, so a key in both hides the
+ // shared column, except in the tables that translate the whole interface.
+ for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) {
+ let both: Vec<_> = table.iter().map(|(key, _)| *key).filter(|key| shared.contains(key)).collect();
+ assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}");
}
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "French entries for unknown keys: {stale:?}");
- let placeholders = |s: &str| {
- let mut names = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- names.sort();
+
+ // A key is current while the interface or another catalog has it.
+ let interface = interface_strings();
+ let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key));
+ for (name, keys) in &catalogs {
+ let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect();
+ stale.sort();
+ assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}");
+ }
+ for (name, keys) in &contexts {
+ let unique: HashSet<_> = keys.iter().collect();
+ assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys");
+ let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect();
+ assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}");
+ }
+
+ let placeholders = |s: &'static str| {
+ let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect();
+ names.sort_unstable();
names
};
- let shared_french = TABLE.iter().filter_map(|(key, row)| row.get(1).map(|text| (*key, *text)));
- for (key, translation) in fr::TABLE.iter().copied().chain(shared_french) {
- assert!(!translation.trim().is_empty(), "empty {key}");
- assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
- assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
- assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
+ let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("...");
+ let colon = |s: &str| s.ends_with(':') || s.ends_with(':');
+ // Full-width punctuation carries its own spacing.
+ let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c);
+ for (lang, key, text) in entries {
+ assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}");
+ assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}");
+ // Dialog ellipses, label colons and padded captions stay as in English.
+ assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}");
+ assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}");
+ if !text.starts_with(full_width) {
+ assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}");
+ }
+ if !text.ends_with(full_width) {
+ assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}");
+ }
}
- assert_eq!(tr("fr", "Group"), "Associer");
- }
-
- /// Interface strings that read the same in French: names, abbreviations, numbering samples, key
- /// symbols and words French shares with English.
- const SAME_IN_FRENCH: &[&str] = &[
- " OK ",
- " (auto)",
- "+Bin",
- "+LOC",
- "A, B, C, D...",
- "Angle",
- "Articles",
- "Bunri-Kinshi",
- "Commits",
- "Composition",
- "Compression",
- "Conditions",
- "Cyan",
- "DesignCraft",
- "Direction",
- "Distance",
- "Document",
- "Document…",
- "Ellipse",
- "Extras",
- "Fractions",
- "GREP",
- "Hindi",
- "Horizontal",
- "I, II, III, IV...",
- "Image",
- "Index",
- "Interface",
- "Justification",
- "Justification…",
- "Kanji",
- "Kenten",
- "Lab",
- "Ligatures",
- "Magenta",
- "Maximum",
- "Menus",
- "Menus…",
- "Minimum",
- "Mobile",
- "Normal",
- "Normal (W)",
- "Notes",
- "OK",
- "OpenType",
- "Options",
- "Orange",
- "Orientation",
- "PDF/A-2b",
- "PDF/X-4",
- "Page",
- "Pages",
- "Pathfinder",
- "Picas",
- "Portrait",
- "Position",
- "Radial",
- "Rectangle",
- "Rensuuji",
- "Rotation",
- "Ruby",
- "Ruby…",
- "Satin",
- "Saturation",
- "Script",
- "Scripts",
- "Section",
- "Serif",
- "Shatai",
- "Signature",
- "Simple",
- "Structure",
- "Style",
- "Styles",
- "Tate-Chu-Yoko",
- "Triangle",
- "Version",
- "Vertical",
- "Violet",
- "Warichu",
- "Web",
- "a, b, c, d...",
- "document",
- "i, ii, iii, iv...",
- "pages",
- "ΔLOC",
- "⇧",
- "−Bin",
- "−LOC",
- "⌃",
- "⌘",
- "⌥",
- ];
-
- #[test]
- fn french_translates_every_interface_string() {
- let keys = interface_strings();
- // A new English string needs a French entry (or a place on `SAME_IN_FRENCH`) before it ships.
- let mut untranslated: Vec<_> = keys.iter().filter(|key| tr("fr", key) == **key && !SAME_IN_FRENCH.contains(key)).collect();
- untranslated.sort();
- assert!(untranslated.is_empty(), "French falls back to English for: {untranslated:?}");
- for key in SAME_IN_FRENCH {
- assert!(keys.contains(key), "SAME_IN_FRENCH lists a string the interface no longer has: {key}");
- assert_eq!(tr("fr", key), *key, "SAME_IN_FRENCH lists a string French translates: {key}");
- }
- for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) {
- assert!(tr("fr", tool.label).starts_with("Outil "), "{}", tool.id);
- }
- assert_eq!(tr("fr", "Selection Tool"), "Outil Sélection");
- assert_eq!(tr("fr", "Don't Save"), "Ne pas enregistrer");
}
#[test]
- fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() {
- let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), uk::TABLE.len(), "duplicate Ukrainian keys");
- let mut keys = std::collections::HashSet::new();
- keys.extend(TABLE.iter().map(|(key, _)| *key));
- keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
- keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
- // A new English string falls back to English until it is translated, so only stale
- // entries (keys no catalog knows any more) fail.
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "Ukrainian entries for unknown keys: {stale:?}");
- let placeholders =
- |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- for (key, translation) in uk::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- let mut source = placeholders(key);
- let mut translated = placeholders(translation);
- source.sort();
- translated.sort();
- assert_eq!(source, translated, "placeholders: {key}");
+ fn only_arabic_lays_out_right_to_left() {
+ for (code, _) in LANGUAGES {
+ assert_eq!(is_rtl(code), *code == "ar", "{code}");
+ }
+ }
+
+ /// Built-in style and workspace names follow the interface language; names people give their
+ /// own styles and workspaces are document data and stay as typed.
+ #[test]
+ fn only_built_in_style_and_workspace_names_are_translated() {
+ for (code, _) in LANGUAGES {
+ for name in [designcraft_doc::NO_PARA_STYLE, designcraft_doc::BASIC_PARAGRAPH, designcraft_doc::NO_CHAR_STYLE] {
+ assert_eq!(style_name(code, name), tr(code, name), "{code}");
+ }
+ assert_eq!(workspace_name(code, "Essentials"), tr(code, "Essentials"), "{code}");
+ for custom in ["Group", "Columns", "My layout style"] {
+ assert_eq!(style_name(code, custom), custom, "{code}");
+ assert_eq!(workspace_name(code, custom), custom, "{code}");
+ }
}
- assert_eq!(tr_context("uk", "Columns", "table"), "Стовпці");
- assert_eq!(tr("uk", "Columns"), "Колонки");
- assert_eq!(tr_context("uk", "Group", "selection"), "Група");
- // Step and Repeat's grid columns are not a text frame's.
- assert_eq!(tr_context("uk", "Columns:", "grid"), "Стовпці:");
- assert_eq!(tr_context("ja", "Columns:", "grid"), "列:");
- assert_eq!(tr("ja", "Columns:"), "段:");
- assert_eq!(tr("uk", "Group"), "Згрупувати");
- assert_eq!(tr_context("", "Columns", "table"), "Columns");
- assert_eq!(tr_context("ja", "Span Columns", "paragraphLayout"), "段抜き");
- assert_eq!(tr("ja", "Span Columns"), "段抜きと段分割");
- assert_eq!(tr_context("uk", "Custom caption", "custom"), "Custom caption");
- assert_eq!(tr("uk", "through"), "включно");
- assert_eq!(tr("uk", "up to"), "не включно");
- assert!(!is_rtl("uk"));
- assert_eq!(tr("uk", "Type"), "Текст");
- assert_eq!(tr("uk", "Spread"), "Розворот");
- assert_eq!(tr("uk", "Story"), "Текстовий матеріал");
- assert_eq!(tr("uk", "Footer Rows"), "Рядки нижнього колонтитула");
- assert_eq!(style_name("uk", "[Basic Paragraph]"), "[Основний абзац]");
- assert_eq!(style_name("uk", "My layout style"), "My layout style");
- assert_eq!(workspace_name("uk", "Essentials"), "Основне");
- assert_eq!(workspace_name("uk", "My workspace"), "My workspace");
}
#[test]
- fn georgian_catalog_preserves_placeholders_and_counts() {
- let catalog: HashMap<_, _> = ka::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), ka::TABLE.len(), "duplicate Georgian keys");
- let placeholders = |s: &str| {
- s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>()
- };
- for (key, translation) in ka::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
- assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
- // Georgian has no capitals in running text: Mtavruli never appears in the interface.
- assert!(!translation.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "Mtavruli: {key}");
- }
- assert_eq!(tr("ka", "File"), "ფაილი");
- assert_eq!(tr("ka", "Save As…"), "შენახვა როგორც…");
- assert_eq!(tr("ka", "Unknown label"), "Unknown label");
- assert!(!is_rtl("ka"));
+ fn georgian_counts_use_category_captions_for_every_number() {
for n in [1, 2, 5, 21] {
assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}"));
}
@@ -4941,62 +4807,6 @@ mod tests {
assert_eq!(count_label("", "objects", 2), "2 objects");
}
- #[test]
- fn russian_catalog_covers_existing_keys_and_preserves_placeholders() {
- let catalog: std::collections::HashMap<_, _> = ru::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), ru::TABLE.len(), "duplicate Russian keys");
- let mut keys = std::collections::HashSet::new();
- keys.extend(TABLE.iter().map(|(key, _)| *key));
- keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
- keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
- keys.extend(it::TABLE.iter().map(|(key, _)| *key));
- keys.extend(ja::TABLE.iter().map(|(key, _)| *key));
- keys.extend(uk::TABLE.iter().map(|(key, _)| *key));
- // A new English string falls back to English until it is translated, so only stale
- // entries (keys no catalog knows any more) fail.
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "Russian entries for unknown keys: {stale:?}");
- let placeholders =
- |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- for (key, translation) in ru::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
- assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
- assert!(!translation.contains(['ґ', 'є', 'і', 'ї', 'Ґ', 'Є', 'І', 'Ї']), "Ukrainian letter in {key}");
- let mut source = placeholders(key);
- let mut translated = placeholders(translation);
- source.sort();
- translated.sort();
- assert_eq!(source, translated, "placeholders: {key}");
- }
- }
-
- #[test]
- fn russian_is_selectable_ltr_and_resolves_contexts_styles_and_menu_paths() {
- assert!(LANGUAGES.contains(&("ru", "Русский")));
- assert!(!is_rtl("ru"));
- assert_eq!(tr("ru", "File"), "Файл");
- assert_eq!(tr("ru", "Type"), "Текст");
- assert_eq!(tr("ru", "Spread"), "Разворот");
- assert_eq!(tr("ru", "Story"), "Материал");
- assert_eq!(tr("ru", "Unknown label"), "Unknown label");
- assert_eq!(tr_context("ru", "Columns", "table"), "Столбцы");
- assert_eq!(tr("ru", "Columns"), "Колонки");
- assert_eq!(tr_context("ru", "Group", "selection"), "Группа");
- assert_eq!(tr("ru", "Group"), "Сгруппировать");
- assert_eq!(tr_context("ru", "Custom caption", "custom"), "Custom caption");
- assert_eq!(tr("ru", "through"), "включительно");
- assert_eq!(tr("ru", "up to"), "не включая");
- assert_eq!(tr("ru", "Edit/Spelling"), "Редактирование/Орфография");
- assert_eq!(tr("ru", "Type/Insert Special Character/Quotation Marks"), "Текст/Вставить специальный символ/Кавычки");
- assert_eq!(style_name("ru", "[Basic Paragraph]"), "[Основной абзац]");
- assert_eq!(style_name("ru", "My layout style"), "My layout style");
- assert_eq!(workspace_name("ru", "Essentials"), "Основное");
- assert_eq!(workspace_name("ru", "My workspace"), "My workspace");
- // Fixed standard names stay identical.
- assert_eq!(tr("ru", "PDF/X-4"), "PDF/X-4");
- }
-
#[test]
fn russian_counts_use_category_captions_for_every_number() {
assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first(""));
@@ -5009,45 +4819,6 @@ mod tests {
}
}
- #[test]
- fn polish_catalog_covers_existing_keys_and_preserves_placeholders() {
- let catalog: HashMap<_, _> = pl::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), pl::TABLE.len(), "duplicate Polish keys");
- let mut keys = std::collections::HashSet::new();
- keys.extend(TABLE.iter().map(|(key, _)| *key));
- for table in [ar::TABLE, pt_br::TABLE, ja::TABLE, it::TABLE, uk::TABLE, de::TABLE, fr::TABLE, ru::TABLE, cs::TABLE] {
- keys.extend(table.iter().map(|(key, _)| *key));
- }
- // A new English string falls back to English until it is translated; only stale entries fail.
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "Polish entries for unknown keys: {stale:?}");
- let placeholders =
- |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- for (key, translation) in pl::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- let mut source = placeholders(key);
- let mut translated = placeholders(translation);
- source.sort();
- translated.sort();
- assert_eq!(source, translated, "placeholders: {key}");
- assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
- }
- assert!(LANGUAGES.contains(&("pl", "Polski")));
- assert!(!is_rtl("pl"));
- assert_eq!(tr("pl", "File"), "Plik");
- assert_eq!(tr("pl", "New Document…"), "Nowy dokument…");
- assert_eq!(tr("pl", "Spread"), "Rozkładówka");
- assert_eq!(tr("pl", "Unknown label"), "Unknown label");
- assert_eq!(style_name("pl", "[Basic Paragraph]"), "[Podstawowy akapit]");
- assert_eq!(workspace_name("pl", "Essentials"), "Podstawowe");
- // A table's columns and grid columns aren't a text frame's; a selection's group isn't the action.
- assert_eq!(tr("pl", "Columns"), "Łamy");
- assert_eq!(tr_context("pl", "Columns", "table"), "Kolumny");
- assert_eq!(tr_context("pl", "Columns:", "grid"), "Łamy:");
- assert_eq!(tr("pl", "Group"), "Grupuj");
- assert_eq!(tr_context("pl", "Group", "selection"), "Grupa");
- }
-
#[test]
fn polish_counts_use_category_captions_for_every_number() {
for n in [0, 1, 2, 5, 12, 22, 25] {
@@ -5058,14 +4829,10 @@ mod tests {
assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}");
}
- /// Every English string the interface passes to `tr`, as far as the catalogs, command and tool
- /// tables and the list of UI-only labels know them.
- fn interface_strings() -> std::collections::HashSet<&'static str> {
- let mut keys = std::collections::HashSet::new();
- keys.extend(TABLE.iter().map(|(key, _)| *key));
- for table in [ar::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE] {
- keys.extend(table.iter().map(|(key, _)| *key));
- }
+ /// Every English string the interface passes to `tr`, as far as the command and tool tables and
+ /// the list of UI-only labels know them.
+ fn interface_strings() -> HashSet<&'static str> {
+ let mut keys = HashSet::new();
// Command labels and menu names reach `tr` from the engine and the UI command table.
for spec in designcraft_engine::command_specs() {
keys.insert(spec.label);
@@ -5085,6 +4852,8 @@ mod tests {
"(unchanged)",
"[No Language]",
"Cell Number:",
+ "Differs from this document",
+ "Digits:",
"Change fill to:",
"Chinese: Simplified",
"Chinese: Traditional",
@@ -5120,6 +4889,7 @@ mod tests {
"Found",
"Grab bag",
"Key Object",
+ "Load Styles",
"layer",
"layers",
"Lines +/−",
@@ -5135,6 +4905,7 @@ mod tests {
"Redefine Document Style",
"Report an issue",
"Reverse the order",
+ "Sort Entries in Alphabetical Order",
"Story {story} ← story {parent}",
"Story Editor — {count} words",
"Text Anchor",
@@ -5156,48 +4927,6 @@ mod tests {
keys
}
- #[test]
- fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() {
- let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect();
- assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys");
- let keys = interface_strings();
- // A new English string falls back to English until it is translated, so only stale
- // entries (keys nothing passes to `tr` any more) fail.
- let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
- assert!(stale.is_empty(), "Czech entries for unknown keys: {stale:?}");
- let placeholders =
- |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
- for (key, translation) in cs::TABLE {
- assert!(!translation.trim().is_empty(), "empty {key}");
- let mut source = placeholders(key);
- let mut translated = placeholders(translation);
- source.sort();
- translated.sort();
- assert_eq!(source, translated, "placeholders: {key}");
- // Dialog ellipses, label colons and padded button captions stay as in English.
- assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
- assert_eq!(key.ends_with(':'), translation.ends_with(':'), "colon: {key}");
- assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
- assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
- }
- assert!(!is_rtl("cs"));
- assert_eq!(tr("cs", "File"), "Soubor");
- assert_eq!(tr("cs", "New Document…"), "Nový dokument…");
- assert_eq!(tr("cs", "Spread"), "Dvoustrana");
- assert_eq!(tr("cs", "Bleed"), "Spadávka");
- assert_eq!(tr("cs", "Leading"), "Proklad");
- assert_eq!(tr("cs", "Unknown label"), "Unknown label");
- assert_eq!(tr_context("cs", "Group", "selection"), "Skupina");
- assert_eq!(tr_context("cs", "Name", "contributors"), "Jméno");
- assert_eq!(tr_context("cs", "Name", "unknown"), "Název");
- assert_eq!(tr("cs", "Group"), "Seskupit");
- assert_eq!(tr_context("cs", "Columns", "table"), tr("cs", "Columns"));
- assert_eq!(style_name("cs", "[Basic Paragraph]"), "[Základní odstavec]");
- assert_eq!(style_name("cs", "My layout style"), "My layout style");
- assert_eq!(workspace_name("cs", "Essentials"), "Základní");
- assert_eq!(workspace_name("cs", "My workspace"), "My workspace");
- }
-
#[test]
fn czech_counts_use_the_plural_form_for_the_number() {
for (n, form) in [(0, "chyb"), (1, "chyba"), (2, "chyby"), (4, "chyby"), (5, "chyb"), (11, "chyb"), (22, "chyb")] {
@@ -5222,51 +4951,4 @@ mod tests {
assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms");
}
}
-
- #[test]
- fn czech_translates_every_tool_name() {
- for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) {
- assert_ne!(tr("cs", tool.label), tool.label, "{}", tool.id);
- assert_eq!(tr("", tool.label), tool.label);
- }
- }
-
- #[test]
- fn japanese_vertical_type_commands_are_translated() {
- for (en, ja) in [
- ("Story Direction", "組み方向"),
- ("Horizontal", "横書き"),
- ("Vertical", "縦書き"),
- ("Tate-Chu-Yoko", "縦中横"),
- ("Ruby…", "ルビ…"),
- ("Kenten", "圏点"),
- ("Warichu", "割り注"),
- ] {
- assert_eq!(tr("ja", en), ja);
- }
- }
-
- #[test]
- fn pdf_export_dialog_strings_are_translated() {
- // Translated labels in every supported language.
- assert_ne!(tr("zh", "Crop Marks"), "Crop Marks");
- assert_ne!(tr("de", "Tagged PDF"), "Tagged PDF");
- assert_ne!(tr("ar", "Include document bleed"), "Include document bleed");
- assert_ne!(tr("pt-br", "Compress images"), "Compress images");
- // English returns the key unchanged.
- assert_eq!(tr("", "Export PDF"), "Export PDF");
- // An unknown string still falls through unchanged.
- assert_eq!(tr("de", "Low Quality Print"), "Low Quality Print");
- // Fixed standard names stay identical in every language.
- assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4");
- }
-
- #[test]
- fn style_row_menu_strings_are_translated_in_every_language() {
- for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
- for key in ["Redefine Style", "Alt-click clears overrides", "Apply \"{name}\", Clear Overrides"] {
- assert_ne!(tr(code, key), key, "{code}: {key}");
- }
- }
- }
}
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
index d47657b09..4f4177cbd 100644
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -2037,7 +2037,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Print Layer", "Ebene drucken"),
("Suppress Text Wrap When Layer is Hidden", "Textumfluss bei ausgeblendeter Ebene unterdrücken"),
// Remaining interface strings.
- ("A custom name", "Ein eigener Name"),
("Close Panel", "Bedienfeld schließen"),
("Default Fill and Stroke", "Standardfläche und -kontur"),
("Distribute Rows Evenly", "Zeilen gleichmäßig verteilen"),
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 18fa402a5..535c87629 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -2460,7 +2460,7 @@ mod tests {
assert_eq!(app.ui.status, "Table: No table selected");
app.ui.language = "cs".into();
app.status_error("Table", "No table selected");
- assert_eq!(app.ui.status, "Tabulka: No table selected");
+ assert_eq!(app.ui.status, format!("{}: No table selected", crate::i18n::tr("cs", "Table")));
}
#[test]
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 2a271324a..87537abd7 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -3367,16 +3367,12 @@ mod tests {
}
#[test]
- fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() {
+ fn screen_mode_shows_the_current_mode() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let mode = |app: &DesignApp, m: &str| checked(app, "view.screenMode", &json!({ "mode": m }));
assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(true), Some(false)));
app.ui.screen_mode = crate::ScreenMode::Preview;
assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true)));
- // Every View submenu has its Japanese path (Edit › Menus shows them).
- for path in submenu_paths("View") {
- assert_ne!(crate::i18n::tr("ja", &path), path, "{path} has no Japanese row");
- }
}
fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) {
@@ -3793,8 +3789,8 @@ mod tests {
run_ui(&mut app, "app.language", &json!({"lang": "de"})).unwrap().unwrap();
assert_eq!(app.ui.language, "de");
assert_eq!(checked(&app, "app.language", &json!({"lang": "de"})), Some(true));
- assert_eq!(crate::i18n::tr(&app.ui.language, "Window"), "Fenster");
assert!(run_ui(&mut app, "app.language", &json!({"lang": "xx"})).unwrap().is_err());
+ assert_eq!(app.ui.language, "de");
// The language is set in Preferences, not from a menu; without `lang` the command opens it.
let mut items = Vec::new();
for (_, entries) in menu_tree() {
@@ -3804,153 +3800,23 @@ mod tests {
run_ui(&mut app, "app.language", &json!({})).unwrap().unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
assert_eq!(app.ui.language, "de");
- app.ui.dialog = None;
-
- run_ui(&mut app, "app.language", &json!({"lang": "zh"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "zh");
- assert_eq!(crate::i18n::tr(&app.ui.language, "File"), "文件");
-
- app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
- let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "ar"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "ar");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "ar"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("ar", title), title, "{title}");
- }
- // Every menu title has a translation (Japanese never matches the English).
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("ja", title), title, "{title}");
- }
-
- run_ui(&mut app, "app.language", &json!({"lang": "pt-br"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "pt-br");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "pt-br"})), Some(true));
- for (title, _) in menu_tree() {
- // "Layout" is the natural Brazilian Portuguese word, so it stays identical
- // to English; every other title must be translated.
- if title != "Layout" {
- assert_ne!(crate::i18n::tr("pt-br", title), title, "{title}");
- }
- }
-
- run_ui(&mut app, "app.language", &json!({"lang": "it"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "it");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "it"})), Some(true));
- for (title, _) in menu_tree() {
- // "File" and "Layout" are the usual Italian menu names, so they stay identical
- // to English; every other title must be translated.
- if title != "File" && title != "Layout" {
- assert_ne!(crate::i18n::tr("it", title), title, "{title}");
- }
- }
-
- run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "ka");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true));
- // Every menu title, item and submenu is translated into Georgian.
- let mut untranslated = Vec::new();
- for (title, entries) in menu_tree() {
- let mut labels = vec![title.to_string()];
- let mut stack: Vec<&Item> = entries.iter().collect();
- while let Some(entry) = stack.pop() {
- match entry {
- Item::Sub(label, children) => {
- labels.push(label.clone());
- stack.extend(children.iter());
- }
- Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()),
- _ => {}
- }
- }
- // Labels without letters ("180°") or point sizes ("72 pt") read the same in every language.
- let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
- untranslated
- .extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str()));
- }
- assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}");
}
#[test]
- fn ukrainian_language_is_selectable_persisted_and_keeps_document_data() {
+ fn every_interface_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
- app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова"})).unwrap();
+ app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова · Zażółć gęślą jaźń"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "uk"})).unwrap().unwrap();
- assert_eq!(checked(&app, "app.language", &json!({"lang": "uk"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("uk", title), title, "{title}");
+ for (code, _) in crate::i18n::LANGUAGES {
+ run_ui(&mut app, "app.language", &json!({"lang": code})).unwrap().unwrap();
+ assert_eq!(app.ui.language, *code);
+ assert_eq!(checked(&app, "app.language", &json!({"lang": code})), Some(true), "{code}");
+ assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(), "{code}");
+ let bytes = serde_json::to_vec(&app.ui).unwrap();
+ let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(restored.language, *code);
}
- let bytes = serde_json::to_vec(&app.ui).unwrap();
- let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
- assert_eq!(restored.language, "uk");
- assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
- assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
- assert!(crate::i18n::LANGUAGES.contains(&("uk", "Українська")), "Preferences lists Ukrainian by its own name");
- }
-
- #[test]
- fn russian_language_is_selectable_persisted_and_keeps_document_data() {
- let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
- app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
- app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мои слова"})).unwrap();
- let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "ru"})).unwrap().unwrap();
- assert_eq!(checked(&app, "app.language", &json!({"lang": "ru"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("ru", title), title, "{title}");
- }
- let bytes = serde_json::to_vec(&app.ui).unwrap();
- let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
- assert_eq!(restored.language, "ru");
- assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
- assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
- assert!(crate::i18n::LANGUAGES.contains(&("ru", "Русский")), "Preferences lists Russian by its own name");
- }
-
- #[test]
- fn czech_language_is_selectable_persisted_and_keeps_document_data() {
- let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
- app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
- app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Příliš žluťoučký kůň"})).unwrap();
- let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "cs"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "cs");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "cs"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("cs", title), title, "{title}");
- }
- let bytes = serde_json::to_vec(&app.ui).unwrap();
- let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
- assert_eq!(restored.language, "cs");
- assert_eq!(crate::i18n::tr(&restored.language, "File"), "Soubor");
- assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
- assert!(crate::i18n::LANGUAGES.contains(&("cs", "Čeština")), "Preferences lists Czech by its own name");
- }
-
- #[test]
- fn polish_language_is_selectable_persisted_and_keeps_document_data() {
- let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
- app.run("file.new", json!({"pages": 2, "facingPages": true})).unwrap();
- app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "Zażółć gęślą jaźń"})).unwrap();
- let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
- run_ui(&mut app, "app.language", &json!({"lang": "pl"})).unwrap().unwrap();
- assert_eq!(app.ui.language, "pl");
- assert_eq!(checked(&app, "app.language", &json!({"lang": "pl"})), Some(true));
- assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
- for (title, _) in menu_tree() {
- assert_ne!(crate::i18n::tr("pl", title), title, "{title}");
- }
- let bytes = serde_json::to_vec(&app.ui).unwrap();
- let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
- assert_eq!(restored.language, "pl");
- assert_eq!(crate::i18n::tr(&restored.language, "File"), "Plik");
- assert!(crate::i18n::LANGUAGES.contains(&("pl", "Polski")), "Preferences lists Polish by its own name");
}
#[test]
@@ -4993,9 +4859,10 @@ mod tests {
fn command_search_matches_labels_in_the_interface_language() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.language = "de".into();
- let items = command_search(&app, "gruppieren");
+ let label = crate::i18n::tr("de", "Group");
+ let items = command_search(&app, &label.to_lowercase());
let group = items.iter().find(|i| i.id == "object.group").unwrap();
- assert_eq!(group.label, "Gruppieren");
+ assert_eq!(group.label, label);
assert_eq!(group.shortcut.as_deref(), Some("Cmd+G"));
assert!(command_search(&app, "group").iter().any(|i| i.id == "object.group"), "English labels match too");
assert!(command_search(&app, " ").is_empty());
@@ -5652,9 +5519,6 @@ mod tests {
// Language follows Settings, as in the suite's other apps, titled in the interface language.
assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Language));
assert_eq!(MAC_APP_MENU.get(settings + 2), Some(&AppMenuItem::Sep));
- for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
- assert_ne!(crate::i18n::tr(code, "Language"), "Language", "{code}");
- }
let label = |command: &str| {
MAC_APP_MENU.iter().find_map(|m| match m {
AppMenuItem::Cmd { label, command: c, .. } if *c == command => Some(*label),
@@ -5662,7 +5526,6 @@ mod tests {
})
};
assert_eq!(label("app.preferences"), Some("Settings…"));
- assert_ne!(crate::i18n::tr("de", "Settings…"), "Settings…");
let ids = MAC_APP_MENU.iter().filter_map(|m| if let AppMenuItem::Cmd { id, .. } = m { Some(*id) } else { None }).collect::>();
assert!(ids.iter().enumerate().all(|(i, id)| !ids[..i].contains(id)), "native ids are unique: {ids:?}");
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index 1cbd623a6..fd0094f9b 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -3148,8 +3148,19 @@ mod tests {
/// are translated, with a sample of each script's figures.
#[test]
fn digits_choices_use_the_reference_names() {
- let shown: Vec = DIGIT_OPTIONS.iter().map(|o| digit_label("fr", *o)).collect();
- assert_eq!(shown, ["Par défaut", "Arabe 0123", "Hindi ٠١٢٣", "Persan ۰۱۲۳", "Natif"].map(String::from));
+ assert_eq!(DIGIT_OPTIONS.map(|o| o.1), ["Default", "Arabic", "Hindi", "Farsi", "Native"]);
+ for (lang, _) in crate::i18n::LANGUAGES {
+ let tr = |s| crate::i18n::tr(lang, s);
+ let shown: Vec = DIGIT_OPTIONS.iter().map(|o| digit_label(lang, *o)).collect();
+ let expected = [
+ tr("Default").to_string(),
+ format!("{} 0123", tr("Arabic")),
+ format!("{} ٠١٢٣", tr("Hindi")),
+ format!("{} ۰۱۲۳", tr("Farsi")),
+ tr("Native").to_string(),
+ ];
+ assert_eq!(shown, expected, "{lang}");
+ }
}
#[test]
From 5e8ef678321cfdd2d18927e2af94d58e5f9d8836 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Thu, 22 Oct 2026 15:38:12 -0400
Subject: [PATCH 38/43] Release 0.12.0
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 42 +++++++++++++++++++++---------------------
Cargo.toml | 2 +-
2 files changed, 22 insertions(+), 22 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 0b022ecfe..e75e8d5a5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -942,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -964,7 +964,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -980,7 +980,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -992,7 +992,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1012,7 +1012,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1025,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1087,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1099,7 +1099,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1112,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1127,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1142,7 +1142,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -1178,7 +1178,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1196,7 +1196,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1215,7 +1215,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1236,7 +1236,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1263,7 +1263,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5814,7 +5814,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 0e728e87f..bc5982898 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
-version = "0.11.0"
+version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
From 079b5bea820291d6f219fb5db2cf3f967481ff69 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Thu, 22 Oct 2026 23:31:37 +0000
Subject: [PATCH 39/43] CI: dev profile keeps line tables only
---
Cargo.toml | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/Cargo.toml b/Cargo.toml
index bc5982898..8a83e0463 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -103,11 +103,16 @@ todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
+# Line tables keep file and line in panics and backtraces; full debug info
+# for every test binary and dependency would multiply the target dir size.
+# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
+debug = false
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
[profile.release]
opt-level = 3
From 40e86d48a521d15d80f370705e13f9362c8bfb00 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Thu, 22 Oct 2026 23:35:30 +0000
Subject: [PATCH 40/43] CI: xtask runs tests with nextest, doctests apart, wasm
in one check
---
xtask/src/main.rs | 71 ++++++++++++++++++++++++++++++++---------------
1 file changed, 49 insertions(+), 22 deletions(-)
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index e730feb51..5000860cc 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -12,7 +12,7 @@ mod stats;
mod version;
use std::path::{Path, PathBuf};
-use std::process::{Command, ExitCode};
+use std::process::{Command, ExitCode, Stdio};
const USAGE: &str = "\
usage: cargo xtask
@@ -21,8 +21,10 @@ commands:
assets check that every icon/image/font/asset is attributed in ASSETS.md
layers enforce the crate dependency layering (plan/architecture.md §3)
pdf-deps verify the pinned PDF backend, including Krilla dependencies
- wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
- ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
+ wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call)
+ test workspace tests (cargo nextest when installed, else cargo test), then doctests
+ ci fmt --check, pdf-deps, clippy -D warnings, test, doctest, assets, layers, wasm
+ (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
@@ -39,6 +41,7 @@ fn main() -> ExitCode {
Some("layers") => cmd_layers(),
Some("pdf-deps") => pdf_deps::run(&root()),
Some("wasm") => cmd_wasm(),
+ Some("test") => cmd_test().and_then(|()| cmd_doctest()),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
@@ -134,21 +137,51 @@ fn wasm_set() -> Result, String> {
.collect())
}
+/// One `cargo check` for the whole set: each crate is checked once, and
+/// `--keep-going` still reports every failing crate. Features unify across the
+/// set, so each crate is checked with the union of the features the set enables.
fn cmd_wasm() -> Result<(), String> {
let set = wasm_set()?;
- let mut results = Vec::new();
+ if set.is_empty() {
+ return Err("no wasm-safe crates found in the workspace metadata".into());
+ }
+ let mut c = cargo();
+ c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]);
for pkg in &set {
- let mut c = cargo();
- c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
- let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
- results.push((pkg.clone(), ok));
+ c.args(["-p", pkg]);
}
- println!("\nwasm32-unknown-unknown check:");
- for (p, ok) in &results {
- println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
- }
- let failed = results.iter().filter(|r| !r.1).count();
- if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
+ run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p ")))?;
+ println!("\nwasm32-unknown-unknown check: ok ({} crates: {})", set.len(), set.join(", "));
+ Ok(())
+}
+
+/// Test targets for the test build. Examples are left out so they aren't
+/// linked; clippy `--all-targets` still compiles them.
+const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"];
+
+fn nextest_available() -> bool {
+ cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
+}
+
+fn cmd_test() -> Result<(), String> {
+ let mut c = cargo();
+ let tool = if nextest_available() {
+ c.args(["nextest", "run"]);
+ "nextest run"
+ } else {
+ eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)");
+ c.arg("test");
+ "test"
+ };
+ c.args(TEST_TARGETS);
+ run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" ")))
+}
+
+/// nextest does not run doctests.
+fn cmd_doctest() -> Result<(), String> {
+ let mut c = cargo();
+ c.args(["test", "--workspace", "--doc"]);
+ run(c, "cargo test --workspace --doc")
}
fn cmd_ci() -> Result<(), String> {
@@ -171,14 +204,8 @@ fn cmd_ci() -> Result<(), String> {
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
}),
),
- (
- "test",
- Box::new(|| {
- let mut c = cargo();
- c.args(["test", "--workspace"]);
- run(c, "cargo test --workspace")
- }),
- ),
+ ("test", Box::new(cmd_test)),
+ ("doctest", Box::new(cmd_doctest)),
("assets", Box::new(|| assets::run(&root()))),
("layers", Box::new(cmd_layers)),
("wasm", Box::new(cmd_wasm)),
From 3ee11391aec7434d318654369a7b7b276882f668 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Thu, 22 Oct 2026 23:39:10 +0000
Subject: [PATCH 41/43] CI: one integration-test binary per crate
---
ASSETS.md | 12 +++++-----
apps/designcraft-cli/tests/{ => it}/cli.rs | 0
apps/designcraft-cli/tests/it/main.rs | 1 +
crates/engine/tests/{ => it}/book_safety.rs | 2 --
crates/engine/tests/{ => it}/browser_print.rs | 1 -
.../character_style_from_selection.rs | 0
.../engine/tests/{ => it}/close_retention.rs | 0
.../{ => it}/collection_state_atomicity.rs | 2 --
.../tests/{ => it}/combined_close_glyph.rs | 0
.../engine/tests/{ => it}/distribute_locks.rs | 0
.../tests/{ => it}/export_page_input.rs | 0
.../tests/{ => it}/insert_pages_bounds.rs | 0
crates/engine/tests/it/main.rs | 23 +++++++++++++++++++
crates/engine/tests/{ => it}/param_keys.rs | 0
crates/engine/tests/{ => it}/pdf_backend.rs | 0
.../tests/{ => it}/preflight_resolution.rs | 0
.../tests/{ => it}/primary_frame_columns.rs | 0
.../tests/{ => it}/save_state_atomicity.rs | 2 --
.../tests/{ => it}/stroke_tint_range.rs | 0
.../engine/tests/{ => it}/text_decorations.rs | 0
.../tests/{ => it}/text_insert_memory.rs | 0
.../tests/{ => it}/text_thread_regressions.rs | 0
.../tests/{ => it}/thread_color_safety.rs | 0
.../epub/tests/{ => it}/export_tag_styles.rs | 0
.../tests/{ => it}/grouped_reading_order.rs | 0
crates/epub/tests/it/main.rs | 2 ++
crates/fonts/tests/it/main.rs | 4 ++++
crates/fonts/tests/{ => it}/system_fonts.rs | 2 --
crates/images/tests/it/main.rs | 1 +
.../{ => it}/rgba_conversion_contract.rs | 0
crates/mcp/tests/{ => it}/automation_roots.rs | 2 --
crates/mcp/tests/it/main.rs | 4 ++++
crates/pdf/tests/image_content_cache.rs | 5 ----
.../tests/{ => it}/gradient_color_spaces.rs | 2 +-
crates/pdf/tests/it/image_content_cache.rs | 3 +++
crates/pdf/tests/it/main.rs | 6 +++++
.../{ => it}/page_entry_search_contract.rs | 0
.../render/tests/{ => it}/blend_over_paper.rs | 0
crates/render/tests/{ => it}/colorize.rs | 0
crates/render/tests/{ => it}/effects.rs | 2 +-
.../render/tests/{ => it}/glyph_retention.rs | 0
crates/render/tests/{ => it}/gradient_inks.rs | 0
crates/render/tests/{ => it}/gradients.rs | 0
crates/render/tests/it/main.rs | 12 ++++++++++
crates/render/tests/{ => it}/pdf_geometry.rs | 0
crates/render/tests/{ => it}/perf.rs | 2 +-
crates/render/tests/{ => it}/placed_pdf.rs | 0
.../render/tests/{ => it}/stroke_alignment.rs | 0
.../tests/{ => it}/text_frame_bottom.rs | 0
.../tests/{ => it}/text_stroke_behind.rs | 0
crates/ui-egui/tests/it/main.rs | 2 ++
.../{ => it}/native_job_lifetime_control.rs | 1 -
52 files changed, 67 insertions(+), 26 deletions(-)
rename apps/designcraft-cli/tests/{ => it}/cli.rs (100%)
create mode 100644 apps/designcraft-cli/tests/it/main.rs
rename crates/engine/tests/{ => it}/book_safety.rs (99%)
rename crates/engine/tests/{ => it}/browser_print.rs (97%)
rename crates/engine/tests/{ => it}/character_style_from_selection.rs (100%)
rename crates/engine/tests/{ => it}/close_retention.rs (100%)
rename crates/engine/tests/{ => it}/collection_state_atomicity.rs (98%)
rename crates/engine/tests/{ => it}/combined_close_glyph.rs (100%)
rename crates/engine/tests/{ => it}/distribute_locks.rs (100%)
rename crates/engine/tests/{ => it}/export_page_input.rs (100%)
rename crates/engine/tests/{ => it}/insert_pages_bounds.rs (100%)
create mode 100644 crates/engine/tests/it/main.rs
rename crates/engine/tests/{ => it}/param_keys.rs (100%)
rename crates/engine/tests/{ => it}/pdf_backend.rs (100%)
rename crates/engine/tests/{ => it}/preflight_resolution.rs (100%)
rename crates/engine/tests/{ => it}/primary_frame_columns.rs (100%)
rename crates/engine/tests/{ => it}/save_state_atomicity.rs (98%)
rename crates/engine/tests/{ => it}/stroke_tint_range.rs (100%)
rename crates/engine/tests/{ => it}/text_decorations.rs (100%)
rename crates/engine/tests/{ => it}/text_insert_memory.rs (100%)
rename crates/engine/tests/{ => it}/text_thread_regressions.rs (100%)
rename crates/engine/tests/{ => it}/thread_color_safety.rs (100%)
rename crates/epub/tests/{ => it}/export_tag_styles.rs (100%)
rename crates/epub/tests/{ => it}/grouped_reading_order.rs (100%)
create mode 100644 crates/epub/tests/it/main.rs
create mode 100644 crates/fonts/tests/it/main.rs
rename crates/fonts/tests/{ => it}/system_fonts.rs (86%)
create mode 100644 crates/images/tests/it/main.rs
rename crates/images/tests/{ => it}/rgba_conversion_contract.rs (100%)
rename crates/mcp/tests/{ => it}/automation_roots.rs (99%)
create mode 100644 crates/mcp/tests/it/main.rs
delete mode 100644 crates/pdf/tests/image_content_cache.rs
rename crates/pdf/tests/{ => it}/gradient_color_spaces.rs (98%)
create mode 100644 crates/pdf/tests/it/image_content_cache.rs
create mode 100644 crates/pdf/tests/it/main.rs
rename crates/pdf/tests/{ => it}/page_entry_search_contract.rs (100%)
rename crates/render/tests/{ => it}/blend_over_paper.rs (100%)
rename crates/render/tests/{ => it}/colorize.rs (100%)
rename crates/render/tests/{ => it}/effects.rs (99%)
rename crates/render/tests/{ => it}/glyph_retention.rs (100%)
rename crates/render/tests/{ => it}/gradient_inks.rs (100%)
rename crates/render/tests/{ => it}/gradients.rs (100%)
create mode 100644 crates/render/tests/it/main.rs
rename crates/render/tests/{ => it}/pdf_geometry.rs (100%)
rename crates/render/tests/{ => it}/perf.rs (98%)
rename crates/render/tests/{ => it}/placed_pdf.rs (100%)
rename crates/render/tests/{ => it}/stroke_alignment.rs (100%)
rename crates/render/tests/{ => it}/text_frame_bottom.rs (100%)
rename crates/render/tests/{ => it}/text_stroke_behind.rs (100%)
create mode 100644 crates/ui-egui/tests/it/main.rs
rename crates/ui-egui/tests/{ => it}/native_job_lifetime_control.rs (98%)
diff --git a/ASSETS.md b/ASSETS.md
index 40e8a97b4..407a26c92 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
-| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon |
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
| `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/it/cli.rs
similarity index 100%
rename from apps/designcraft-cli/tests/cli.rs
rename to apps/designcraft-cli/tests/it/cli.rs
diff --git a/apps/designcraft-cli/tests/it/main.rs b/apps/designcraft-cli/tests/it/main.rs
new file mode 100644
index 000000000..26710c101
--- /dev/null
+++ b/apps/designcraft-cli/tests/it/main.rs
@@ -0,0 +1 @@
+mod cli;
diff --git a/crates/engine/tests/book_safety.rs b/crates/engine/tests/it/book_safety.rs
similarity index 99%
rename from crates/engine/tests/book_safety.rs
rename to crates/engine/tests/it/book_safety.rs
index 74c96815a..01c527be5 100644
--- a/crates/engine/tests/book_safety.rs
+++ b/crates/engine/tests/it/book_safety.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
diff --git a/crates/engine/tests/browser_print.rs b/crates/engine/tests/it/browser_print.rs
similarity index 97%
rename from crates/engine/tests/browser_print.rs
rename to crates/engine/tests/it/browser_print.rs
index fda224b90..208a6cde8 100644
--- a/crates/engine/tests/browser_print.rs
+++ b/crates/engine/tests/it/browser_print.rs
@@ -1,5 +1,4 @@
//! Browser Print must reject native spooler operations without trapping the editor.
-#![cfg(target_arch = "wasm32")]
use designcraft_engine::Session;
use serde_json::json;
diff --git a/crates/engine/tests/character_style_from_selection.rs b/crates/engine/tests/it/character_style_from_selection.rs
similarity index 100%
rename from crates/engine/tests/character_style_from_selection.rs
rename to crates/engine/tests/it/character_style_from_selection.rs
diff --git a/crates/engine/tests/close_retention.rs b/crates/engine/tests/it/close_retention.rs
similarity index 100%
rename from crates/engine/tests/close_retention.rs
rename to crates/engine/tests/it/close_retention.rs
diff --git a/crates/engine/tests/collection_state_atomicity.rs b/crates/engine/tests/it/collection_state_atomicity.rs
similarity index 98%
rename from crates/engine/tests/collection_state_atomicity.rs
rename to crates/engine/tests/it/collection_state_atomicity.rs
index b73eefa7e..cb6a0db94 100644
--- a/crates/engine/tests/collection_state_atomicity.rs
+++ b/crates/engine/tests/it/collection_state_atomicity.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
use designcraft_engine::Session;
use serde_json::json;
diff --git a/crates/engine/tests/combined_close_glyph.rs b/crates/engine/tests/it/combined_close_glyph.rs
similarity index 100%
rename from crates/engine/tests/combined_close_glyph.rs
rename to crates/engine/tests/it/combined_close_glyph.rs
diff --git a/crates/engine/tests/distribute_locks.rs b/crates/engine/tests/it/distribute_locks.rs
similarity index 100%
rename from crates/engine/tests/distribute_locks.rs
rename to crates/engine/tests/it/distribute_locks.rs
diff --git a/crates/engine/tests/export_page_input.rs b/crates/engine/tests/it/export_page_input.rs
similarity index 100%
rename from crates/engine/tests/export_page_input.rs
rename to crates/engine/tests/it/export_page_input.rs
diff --git a/crates/engine/tests/insert_pages_bounds.rs b/crates/engine/tests/it/insert_pages_bounds.rs
similarity index 100%
rename from crates/engine/tests/insert_pages_bounds.rs
rename to crates/engine/tests/it/insert_pages_bounds.rs
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
new file mode 100644
index 000000000..14555d3ee
--- /dev/null
+++ b/crates/engine/tests/it/main.rs
@@ -0,0 +1,23 @@
+#[cfg(not(target_arch = "wasm32"))]
+mod book_safety;
+#[cfg(target_arch = "wasm32")]
+mod browser_print;
+mod character_style_from_selection;
+mod close_retention;
+#[cfg(not(target_arch = "wasm32"))]
+mod collection_state_atomicity;
+mod combined_close_glyph;
+mod distribute_locks;
+mod export_page_input;
+mod insert_pages_bounds;
+mod param_keys;
+mod pdf_backend;
+mod preflight_resolution;
+mod primary_frame_columns;
+#[cfg(not(target_arch = "wasm32"))]
+mod save_state_atomicity;
+mod stroke_tint_range;
+mod text_decorations;
+mod text_insert_memory;
+mod text_thread_regressions;
+mod thread_color_safety;
diff --git a/crates/engine/tests/param_keys.rs b/crates/engine/tests/it/param_keys.rs
similarity index 100%
rename from crates/engine/tests/param_keys.rs
rename to crates/engine/tests/it/param_keys.rs
diff --git a/crates/engine/tests/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs
similarity index 100%
rename from crates/engine/tests/pdf_backend.rs
rename to crates/engine/tests/it/pdf_backend.rs
diff --git a/crates/engine/tests/preflight_resolution.rs b/crates/engine/tests/it/preflight_resolution.rs
similarity index 100%
rename from crates/engine/tests/preflight_resolution.rs
rename to crates/engine/tests/it/preflight_resolution.rs
diff --git a/crates/engine/tests/primary_frame_columns.rs b/crates/engine/tests/it/primary_frame_columns.rs
similarity index 100%
rename from crates/engine/tests/primary_frame_columns.rs
rename to crates/engine/tests/it/primary_frame_columns.rs
diff --git a/crates/engine/tests/save_state_atomicity.rs b/crates/engine/tests/it/save_state_atomicity.rs
similarity index 98%
rename from crates/engine/tests/save_state_atomicity.rs
rename to crates/engine/tests/it/save_state_atomicity.rs
index ac0f30f58..2ced20ef0 100644
--- a/crates/engine/tests/save_state_atomicity.rs
+++ b/crates/engine/tests/it/save_state_atomicity.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
use std::path::Path;
use std::sync::Arc;
diff --git a/crates/engine/tests/stroke_tint_range.rs b/crates/engine/tests/it/stroke_tint_range.rs
similarity index 100%
rename from crates/engine/tests/stroke_tint_range.rs
rename to crates/engine/tests/it/stroke_tint_range.rs
diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/it/text_decorations.rs
similarity index 100%
rename from crates/engine/tests/text_decorations.rs
rename to crates/engine/tests/it/text_decorations.rs
diff --git a/crates/engine/tests/text_insert_memory.rs b/crates/engine/tests/it/text_insert_memory.rs
similarity index 100%
rename from crates/engine/tests/text_insert_memory.rs
rename to crates/engine/tests/it/text_insert_memory.rs
diff --git a/crates/engine/tests/text_thread_regressions.rs b/crates/engine/tests/it/text_thread_regressions.rs
similarity index 100%
rename from crates/engine/tests/text_thread_regressions.rs
rename to crates/engine/tests/it/text_thread_regressions.rs
diff --git a/crates/engine/tests/thread_color_safety.rs b/crates/engine/tests/it/thread_color_safety.rs
similarity index 100%
rename from crates/engine/tests/thread_color_safety.rs
rename to crates/engine/tests/it/thread_color_safety.rs
diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/it/export_tag_styles.rs
similarity index 100%
rename from crates/epub/tests/export_tag_styles.rs
rename to crates/epub/tests/it/export_tag_styles.rs
diff --git a/crates/epub/tests/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs
similarity index 100%
rename from crates/epub/tests/grouped_reading_order.rs
rename to crates/epub/tests/it/grouped_reading_order.rs
diff --git a/crates/epub/tests/it/main.rs b/crates/epub/tests/it/main.rs
new file mode 100644
index 000000000..69af15eaa
--- /dev/null
+++ b/crates/epub/tests/it/main.rs
@@ -0,0 +1,2 @@
+mod export_tag_styles;
+mod grouped_reading_order;
diff --git a/crates/fonts/tests/it/main.rs b/crates/fonts/tests/it/main.rs
new file mode 100644
index 000000000..1737c00b9
--- /dev/null
+++ b/crates/fonts/tests/it/main.rs
@@ -0,0 +1,4 @@
+// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+mod system_fonts;
diff --git a/crates/fonts/tests/system_fonts.rs b/crates/fonts/tests/it/system_fonts.rs
similarity index 86%
rename from crates/fonts/tests/system_fonts.rs
rename to crates/fonts/tests/it/system_fonts.rs
index 62310c34b..66db0f5b1 100644
--- a/crates/fonts/tests/system_fonts.rs
+++ b/crates/fonts/tests/it/system_fonts.rs
@@ -1,7 +1,5 @@
//! A new session finds the installed system fonts by family name (#22). One test in its own
//! process, so the process-wide database starts fresh, as it does when the app starts.
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use designcraft_fonts::{FontDb, system_font_dirs};
diff --git a/crates/images/tests/it/main.rs b/crates/images/tests/it/main.rs
new file mode 100644
index 000000000..d2f76fb71
--- /dev/null
+++ b/crates/images/tests/it/main.rs
@@ -0,0 +1 @@
+mod rgba_conversion_contract;
diff --git a/crates/images/tests/rgba_conversion_contract.rs b/crates/images/tests/it/rgba_conversion_contract.rs
similarity index 100%
rename from crates/images/tests/rgba_conversion_contract.rs
rename to crates/images/tests/it/rgba_conversion_contract.rs
diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/it/automation_roots.rs
similarity index 99%
rename from crates/mcp/tests/automation_roots.rs
rename to crates/mcp/tests/it/automation_roots.rs
index 9380b1987..871945920 100644
--- a/crates/mcp/tests/automation_roots.rs
+++ b/crates/mcp/tests/it/automation_roots.rs
@@ -3,8 +3,6 @@
//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the
//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing
//! is written outside.
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs
new file mode 100644
index 000000000..be744719e
--- /dev/null
+++ b/crates/mcp/tests/it/main.rs
@@ -0,0 +1,4 @@
+// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+mod automation_roots;
diff --git a/crates/pdf/tests/image_content_cache.rs b/crates/pdf/tests/image_content_cache.rs
deleted file mode 100644
index dd3d413d0..000000000
--- a/crates/pdf/tests/image_content_cache.rs
+++ /dev/null
@@ -1,5 +0,0 @@
-//! Run public contracts with the production content-cache hasher.
-#[path = "support/image_content_cache_contract.rs"]
-mod contracts;
-#[path = "support/image_fixtures.rs"]
-mod image_fixtures;
diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/it/gradient_color_spaces.rs
similarity index 98%
rename from crates/pdf/tests/gradient_color_spaces.rs
rename to crates/pdf/tests/it/gradient_color_spaces.rs
index 05806a120..d906413e1 100644
--- a/crates/pdf/tests/gradient_color_spaces.rs
+++ b/crates/pdf/tests/it/gradient_color_spaces.rs
@@ -42,7 +42,7 @@ fn mixed_gradient_export_survives_in_subprocess() {
const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD";
if std::env::var_os(CHILD).is_none() {
let out = std::process::Command::new(std::env::current_exe().unwrap())
- .args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"])
+ .args(["--exact", "gradient_color_spaces::mixed_gradient_export_survives_in_subprocess", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
diff --git a/crates/pdf/tests/it/image_content_cache.rs b/crates/pdf/tests/it/image_content_cache.rs
new file mode 100644
index 000000000..ec0f666c0
--- /dev/null
+++ b/crates/pdf/tests/it/image_content_cache.rs
@@ -0,0 +1,3 @@
+//! Run public contracts with the production content-cache hasher.
+#[path = "../support/image_content_cache_contract.rs"]
+mod contracts;
diff --git a/crates/pdf/tests/it/main.rs b/crates/pdf/tests/it/main.rs
new file mode 100644
index 000000000..e2e092e2f
--- /dev/null
+++ b/crates/pdf/tests/it/main.rs
@@ -0,0 +1,6 @@
+#[path = "../support/image_fixtures.rs"]
+mod image_fixtures;
+
+mod gradient_color_spaces;
+mod image_content_cache;
+mod page_entry_search_contract;
diff --git a/crates/pdf/tests/page_entry_search_contract.rs b/crates/pdf/tests/it/page_entry_search_contract.rs
similarity index 100%
rename from crates/pdf/tests/page_entry_search_contract.rs
rename to crates/pdf/tests/it/page_entry_search_contract.rs
diff --git a/crates/render/tests/blend_over_paper.rs b/crates/render/tests/it/blend_over_paper.rs
similarity index 100%
rename from crates/render/tests/blend_over_paper.rs
rename to crates/render/tests/it/blend_over_paper.rs
diff --git a/crates/render/tests/colorize.rs b/crates/render/tests/it/colorize.rs
similarity index 100%
rename from crates/render/tests/colorize.rs
rename to crates/render/tests/it/colorize.rs
diff --git a/crates/render/tests/effects.rs b/crates/render/tests/it/effects.rs
similarity index 99%
rename from crates/render/tests/effects.rs
rename to crates/render/tests/it/effects.rs
index a9420fbcb..230913173 100644
--- a/crates/render/tests/effects.rs
+++ b/crates/render/tests/it/effects.rs
@@ -3,7 +3,7 @@
//!
//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur
//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended
-//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`.
+//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test it effects::`.
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
diff --git a/crates/render/tests/glyph_retention.rs b/crates/render/tests/it/glyph_retention.rs
similarity index 100%
rename from crates/render/tests/glyph_retention.rs
rename to crates/render/tests/it/glyph_retention.rs
diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/it/gradient_inks.rs
similarity index 100%
rename from crates/render/tests/gradient_inks.rs
rename to crates/render/tests/it/gradient_inks.rs
diff --git a/crates/render/tests/gradients.rs b/crates/render/tests/it/gradients.rs
similarity index 100%
rename from crates/render/tests/gradients.rs
rename to crates/render/tests/it/gradients.rs
diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs
new file mode 100644
index 000000000..52000ae2d
--- /dev/null
+++ b/crates/render/tests/it/main.rs
@@ -0,0 +1,12 @@
+mod blend_over_paper;
+mod colorize;
+mod effects;
+mod glyph_retention;
+mod gradient_inks;
+mod gradients;
+mod pdf_geometry;
+mod perf;
+mod placed_pdf;
+mod stroke_alignment;
+mod text_frame_bottom;
+mod text_stroke_behind;
diff --git a/crates/render/tests/pdf_geometry.rs b/crates/render/tests/it/pdf_geometry.rs
similarity index 100%
rename from crates/render/tests/pdf_geometry.rs
rename to crates/render/tests/it/pdf_geometry.rs
diff --git a/crates/render/tests/perf.rs b/crates/render/tests/it/perf.rs
similarity index 98%
rename from crates/render/tests/perf.rs
rename to crates/render/tests/it/perf.rs
index 1f23f88f4..46f33ab62 100644
--- a/crates/render/tests/perf.rs
+++ b/crates/render/tests/it/perf.rs
@@ -1,6 +1,6 @@
//! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock
//! timings are only meaningful on an idle machine — run with
-//! `cargo test --release -p designcraft-render --test perf -- --ignored`).
+//! `cargo test --release -p designcraft-render --test it perf:: -- --ignored`).
use std::time::Instant;
diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/it/placed_pdf.rs
similarity index 100%
rename from crates/render/tests/placed_pdf.rs
rename to crates/render/tests/it/placed_pdf.rs
diff --git a/crates/render/tests/stroke_alignment.rs b/crates/render/tests/it/stroke_alignment.rs
similarity index 100%
rename from crates/render/tests/stroke_alignment.rs
rename to crates/render/tests/it/stroke_alignment.rs
diff --git a/crates/render/tests/text_frame_bottom.rs b/crates/render/tests/it/text_frame_bottom.rs
similarity index 100%
rename from crates/render/tests/text_frame_bottom.rs
rename to crates/render/tests/it/text_frame_bottom.rs
diff --git a/crates/render/tests/text_stroke_behind.rs b/crates/render/tests/it/text_stroke_behind.rs
similarity index 100%
rename from crates/render/tests/text_stroke_behind.rs
rename to crates/render/tests/it/text_stroke_behind.rs
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
new file mode 100644
index 000000000..2d07c9629
--- /dev/null
+++ b/crates/ui-egui/tests/it/main.rs
@@ -0,0 +1,2 @@
+#[cfg(not(target_arch = "wasm32"))]
+mod native_job_lifetime_control;
diff --git a/crates/ui-egui/tests/native_job_lifetime_control.rs b/crates/ui-egui/tests/it/native_job_lifetime_control.rs
similarity index 98%
rename from crates/ui-egui/tests/native_job_lifetime_control.rs
rename to crates/ui-egui/tests/it/native_job_lifetime_control.rs
index 2cf82e5bd..b7fa87214 100644
--- a/crates/ui-egui/tests/native_job_lifetime_control.rs
+++ b/crates/ui-egui/tests/it/native_job_lifetime_control.rs
@@ -1,5 +1,4 @@
//! Own a real native Job across last close. This does not run the asynchronous worker.
-#![cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
From 0fd6e06ad6a7921c0d5df08b3d45434910827d52 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Fri, 23 Oct 2026 01:17:55 +0000
Subject: [PATCH 42/43] CI: install nextest for the test step
---
.github/workflows/ci.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 72bac7ef7..c27fdfe10 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -57,6 +57,10 @@ jobs:
--target wasm32-unknown-unknown
rustup default stable
+ - uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26
+ with:
+ tool: cargo-nextest@0.9.145
+
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
From 62148670bd6ee3522c116cc1b78e2d6be0f16765 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Fri, 23 Oct 2026 02:54:21 +0000
Subject: [PATCH 43/43] CI: apt packages through craft-common-actions' cached
install
---
.github/workflows/ci.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c27fdfe10..d5b820d24 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,10 +45,10 @@ jobs:
persist-credentials: false
- name: Install Linux build dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \
+ uses: storytold/craft-common-actions/actions/install-apt-packages@main
+ with:
+ packages: >-
+ pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)