Files
designcraft/crates/doc/src/lib.rs
T
aiongg 18ae8e4761 Merge upstream main a184db6f8 with our 15 PRs (batch-0) into next
Conflicts resolved per file (both sides kept unless noted):
crates/engine/src/atomic.rs | 1 | main's: `write_file` removed (main routes command writes through `crate::io::write_file`); its one caller in file.rs now uses `crate::io::write_file`
crates/engine/src/cmd/datamerge/fill.rs | 1 | next's `Graphic` literal (has `clip` and `wrap`, which next's `Graphic` carries)
crates/engine/src/cmd/datamerge/mod.rs | 1 | main's `read_path_capped` (crate::io metadata/read_capped, roots checked); next's capped read is the same behaviour
crates/engine/src/cmd/datamerge/parse.rs | 1 | main's (`crate::io::metadata`, `crate::io::Metadata`)
crates/engine/src/cmd/edit.rs | 1 | next's `spread_param(p, "edit.paste")` (the second argument is the command id on both sides)
crates/engine/src/cmd/export.rs | 3 | main's: EPUB params doc, `toc_title` once (main's position; next's duplicate dropped)
crates/engine/src/cmd/file.rs | 7 | place doc: next's (lists page/rect/autoflow params #803 checks); next's recent-file functions + main's `foreign_document`; next's open (load_bytes, warnings, non-DesignCraft refusal) with main's checked `crate::io::read_file` for the bytes; main's `check_write` be
crates/engine/src/cmd/find.rs | 7 | main's Find scope (FindContext, scope/selection/story, continuation, UTF-8 repair) on next's `designcraft_compose::grep::Grep` (dialect GREP): find uses `find_all` capped at MAX_MATCHES, change uses `replacements`/`find_all` (literal change text not expanded), fin
crates/engine/src/cmd/fonts.rs | 1 | next's (`has_style`, resolved/replacement face); `has_style` already compares styles ignoring spacing and case, which is main's `same_style` fix; main's test kept
crates/engine/src/cmd/interchange.rs | 1 | next's `import_idml_report_with_previews` (previews for unreadable links)
crates/engine/src/cmd/layout.rs | 8 | pages.insert: main's overflow-safe `after` + next's checked `parent_ref`; main's duplicateSpread doc; layout.section: next's `SectionEdit` (already keeps unnamed settings, #717) with main's `d.repaginate()` moved into `SectionEdit::apply` (remove and set paths),
crates/engine/src/cmd/links.rs | 5 | next's links.list (no doc clone); main's `text_status` via crate::io; `use std::io::Read` dropped (unused after main's read_capped); test literals main's. `link_missing` (next) uses `crate::io::exists` for the file-guard scan
crates/engine/src/cmd/object.rs | 4 | object.fill/stroke tint-alone: main's (23e1680e7; out-of-range tint is an error, which main's test asserts); object.fit doc: next's (lists `axis`)
crates/engine/src/cmd/paths.rs | 1 | next's (shatai transform on outlines)
crates/engine/src/cmd/place_text.rs | 1 | next's `add_flow_frame` (used by autoflow)
crates/engine/src/cmd/preflight.rs | 2 | next's overset/unplacedStory issue (its `overset_location` already applies the parent transform main's #738 fix adds); next's two test modules kept with main's
crates/engine/src/cmd/scripts.rs | 1 | combined: next's `in_undo_step = false` (#406) + main's full rollback of a failed script (#709)
crates/engine/src/cmd/style.rs | 14 | param docs next's (gradient swatch.create, swatch stops, model colours); create_para next's; rename_style main's structure (rename entry, then `redirect_style_refs`), redirect has next's full reference set (cells, defaults, notes, TOC) + main's never-based-on-it
crates/engine/src/cmd/text.rs | 10 | main's selectSentence/Paragraph/Story + select_around; next's selectionAttrs (mixed) + main's openTypeFeatures/type.feature; main's comments; tracked typing: next's `reserve_command_text` + main's `insert_format_at`; empty-paragraph mark formatting main's (with `
crates/engine/src/cmd/thread.rs | 1 | next's `spread_param(p, "text.thread")`
crates/engine/src/dtd.rs | 1 | next's linear, depth-bounded `expand_parameter_entities` with main's budget (input + 1 MiB, `MAX_EXPANDED_BYTES`) and message ("expand the DTD beyond"); 16 MiB constant removed; both sides' tests pass on message
crates/engine/src/guard.rs | 2 | main's whole-transaction snapshot (docs, active, journal; #742) + next's `in_undo_step` restore; tests both
crates/engine/src/lib.rs | 5 | LoadedGraphic doc next's; next's `new_document_preview`; execute: main's `discard_stale_find_scope()` before next's nested-undo run; modules both (tests_autosize, tests_file_guard, tests_params, tests_roots)
crates/engine/src/recovery.rs | 1 | next's test (superset of main's assertion)
crates/engine/src/tests.rs | 4 | moved tests deduplicated (identical on both sides); next's preflight and direct-selection/kerning tests + main's style-delete, locked-frame and baseline-fit tests
crates/engine/src/tests_idml.rs | 3 | main's order (drop cap and margins tests identical on both sides, once each)
crates/ui-egui/src/canvas.rs | 3 | next's screen_options/DisplayKey block and tests kept; two tests that auto-merge had added twice (parent_guides_on_a_page_are_not_picked_there, baseline_grid_lines_are_the_ones_on_the_page) kept once; main's `typing`/`screen_at`/`click` helpers and its tests clicks
crates/ui-egui/src/control.rs | 3 | main's `count` check and pending budget (control queue + synthetic queue); exact-budget test runs both sides' cases (click 255 on 9,492 pending; drag 9492×255)
crates/ui-egui/src/cursors.rs | 1 | main's (same Pen cursor set)
crates/ui-egui/src/dialogs.rs | 14 | dialog body (newDocument … alert match) is next's text, with main's Find/Change scope reset (`set_field`, `reset_find_scope`, `find_change_params`) and main's Create Guides / Margins and Columns arms ported in; `Dialog::new` ruby section (next) + main's `set_fi
crates/ui-egui/src/i18n.rs | 7 | ka and pl modules, languages, statics and lookups both; `category_first` covers uk, ru, pl, ka and `count_label` keeps next's Czech plurals; shared TABLE union; next's duplicate rows for Auto, Number:, Gutter:, Top:, Bottom:, Inside:, Outside:, Left:, Right: dropped
crates/ui-egui/src/i18n/ar.rs | 2 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/it.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/ja.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/pt_br.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/uk.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/icons.rs | 2 | both tests (main's typography icon, next's distribute icons)
crates/ui-egui/src/lib.rs | 11 | UiState: next's tabs_panel + main's last_color/last_gradient; `cursors` field once; next's SVG paste and paste-on-spread-in-view; next's extensionless document open; opens_as_document next's comment; tests both (next's control bar / key-name tests, main's rich black
crates/ui-egui/src/menus.rs | 15 | app.language doc next's text with pl and ka; UI_COMMANDS both (next's Tabs, Toggle Presentation, Apply None text; main's panel commands, Apply Color, Apply Gradient); Edit › Interface Language stays out (next moved it to Preferences); Type menu main's order with
crates/ui-egui/src/panels/glyphs.rs | 1 | next's (already has main's scroll-bar fix); the scroll-bar test module auto-merge had added twice kept once
crates/ui-egui/src/panels/layers.rs | 3 | next's drag-and-drop `object_rows`, with main's features ported into it: selection reveal (layer opens, row scrolls into view once), selected rows highlighted (also a group whose member is selected), double-click rename in place (`object.rename` queued like
crates/ui-egui/src/panels/mod.rs | 6 | tabs + typography modules and color_panel_tests; SelInfo has wrap_side/wrap_contour and wrap_offsets (offsets from the graphic's own wrap when it is selected); next's swatch_picker returning the chip response; next's content_selection_tests
crates/ui-egui/src/panels/pages.rs | 1 | next's (parents dragged by id)
crates/ui-egui/src/panels/properties.rs | 4 | next's swatch popup (Apply To, Tint); main's four wrap offsets then next's Wrap To / Contour Type; test fixtures main's (`..Default::default()`, no unused builder)
crates/ui-egui/src/panels/swatches.rs | 3 | main's Delete Swatch button + next's New Swatch comment; next's draggable rows; click: main's PICKED + fill/stroke in front, with next's no-apply on double-click
crates/ui-egui/src/toolbar.rs | 2 | main's Apply Color / Gradient / None wells and functions; next's apply_none doc line
crates/compose/src/lib.rs | 5 | next's line loop kept (mark_leading + last_line_mark, wrap segments/carry, grid gyoudori); main's mark-chars base_leading block (auto-merged) stays beside it. `fits`: next's #106 rule (baseline fit for horizontal frames, full descent for paths, vertical text, footnote
crates/compose/src/tests.rs | 5 | next's tests (tombstone set, sliver/one-sided/contour/clipping wrap tests, borders, lists, kenten, grid, anchored wrap, balance, shaped frames) plus main's an_empty_last_paragraph_isnt_overset; main's wrap_object_beside_one_narrow_column_leaves_the_other_alone re-ad
crates/doc/src/assets.rs | 1 | main's doc comment (same meaning)
crates/doc/src/build.rs | 3 | parent_page_for: next's parent_page_for_side (RTL-aware); single_sided_page_shows_first_parent_page test kept once, at main's position
crates/doc/src/item.rs | 1 | next's rotated_group_bounds_follow_the_children_outlines test kept
crates/doc/src/lib.rs | 1 | next's kerning_increment field kept; main's "Bulleted & Numbered" wording
crates/doc/src/page.rs | 1 | next's outer_page_indices + parent_page_for_side (integration of parent-margins with rtl-master-sides); main's parent_page_index dropped, its only caller (build.rs) uses parent_page_for_side
crates/doc/src/slice.rs | 5 | extract: next's `marked()` (get-based, fix_notes/fix_endnotes/fix_marks; main's version indexed slices and could panic); insert_story: main's take() limit on endnote/editorial payload copies with next's id comment, redundant `let ne`/`let nn` dropped; main's excess_note
crates/geom/src/corners.rs | 1 | next's box_outline_rounds_only_closed_corners test kept
crates/render/src/damage.rs | 1 | next's wrapping-anchored-object damage test kept
crates/render/src/fx.rs | 2 | feather: next's choke/corners sigma+join inside main's structure (body and clip at device resolution, with_filters(.., None)); inner glow centre: next's blend `mix` with main's clip argument (bp, xf)
crates/render/src/lib.rs | 3 | next's layer comment; next's front baseline grid draw after items; gradient match on g.kind (main's tuple scrutinee did not match the single-pattern arms)
crates/render/src/text.rs | 5 | paint_glyphs: main's paint_stroke closure structure with next's stroke overprint (overprinting helper, stroke `(TextPaint, bool)`); callers pass next's overprint_fill/overprint_stroke
crates/render/tests/placed_pdf.rs | 1 | main's `near` helper version
crates/tools/src/lib.rs | 1 | main's (PenConvert already declared below, avoids a duplicate variant)
crates/tools/src/pen.rs | 1 | main's module doc
crates/tools/src/select.rs | 1 | next's Drag::Corner variant (live corners) kept
crates/tools/src/tests.rs | 4 | next's tests (content drag, live corners, table border drag, eyedropper matchFrom, fitting handles, pen cursors once); eyedropper_ignores_inactive keeps next's samples_image check (main's exact-JSON compare lacks matchFrom) with main's `..Default::default()` Asset lit
crates/tools/src/text.rs | 2 | next's table border drag (BorderDrag, border_at) kept, then main's comment on locked text frames
crates/tools/src/xform.rs | 2 | main's explicit `Vec<&Item>` for shown children; next's eyedropper matchFrom (integration 796e9d713)
Cargo.toml | 2 | main's craft-pdf workspace dep added (craft-print stays once, in next's craft-libs block); [patch.crates-io] keeps next's winit (rust-windowing #4694 backport) and adds main's krilla patch; main's skylartaylor winit left out (see UNSURE 1)
Cargo.lock | 1 | union: next's craft-atomic and craft-cms entries plus main's craft-pdf entry; needs `cargo` normalisation
README.md | 1 | Open bullet combines INDD import via indd-utils (next) and editable PowerPoint export (main); main's paragraph break kept
contributors/people.toml | 1 | both people kept (switch87 from next, Nmarello from main)
apps/designcraft-cli/src/main.rs | 4 | next's `Export` enum dispatch kept; main's .pptx export added as `Export::Pptx` (run prints slides and warnings, script runs file.exportPptx); main's `--pdf-options` null-as-{} parse; usage lines list .pptx
apps/designcraft/src/main.rs | 3 | `startup.leave()` right after `app.ui` (main #380) and next's macOS `sync_movable` kept after the IME discard; next's Apple Events install kept; main's macOS Dock Quit `with_close_requested_on_terminate` block left out (needs the skylartaylor winit; UNSURE 1)
crates/fonts/src/fontdb.rs | 9 | FONT6 aliases (next) combined with #911 WWS names (main): ScannedFace and both CatalogEntry constructors carry `wws` and `aliases`; `canonical_family` falls back to WWS family names; `exact_style_traits` canonicalises then tries typographic, then WWS names (`named_st
crates/format/src/lib.rs | 1 | tests: both sides kept (next's r7a nullable-default tests; main's oversized ZIP entry/asset/entry-count tests)
crates/idml/src/import.rs | 11 | main's bounded `Package` reader under next's `import_idml_report_with_previews` (metadata read through `pkg.part`); next's `tab_list_repaired` kept; ruler origin: next's `settings.ruler_origin`, and main's guide import reads it (main's private `RulerOrigin` enum and
crates/idml/src/tests.rs | 6 | item-level union: all next tests plus main's new ones (ruler guides, package limits, CJK leading model, liquid page rules module, mojikumi); no test dropped
crates/mcp/src/backend.rs | 1 | test comment merged from both
crates/pdf/src/tests.rs | 1 | tests: both sides kept
crates/pdf/src/text.rs | 4 | next's (skew `tan`, cluster `start`, ruby overprint warning); main had no change here beyond base
crates/textimport/src/docx.rs | 5 | next's (ListCounter integration for list levels, page-break test position); main's side was the older form of the same code
crates/epub/src/lib.rs | 30 | non-test code is next's (StoryWriter holds img_src/images/no_alt/heading_ids; covers main's #787/#791/#782 fixes, plus #314 group transforms, anchored images in images_without_alt, endnote story skipped in the TOC); tests: next's set plus main's extra css_weight_style a
docs/control-protocol.md | 2 | next's rows (ui.paste, count 1–255, documentGrid) and wording
docs/file-format-parity.md | 1 | rows combined (next's ICC notes, main's TIFF indexed colour)
docs/gaps.md | 2 | status change notes unioned; Flex Layout removed from missing (main implemented it), next's Parent Options sentence kept
docs/localization-parity.md | 4 | Polish and Georgian rows; 5 languages beyond the twelve; file list includes cs and ka; both history rows
docs/roadmap.md | 1 | M1 next's (#186 fixed on next), M2 main's IME wording
docs/target-app-parity.md | 6 | next's status line; File formats row next's, UI/UX row main's (IME no longer failing); UI line next's plus main's Swatches detail; one Page tool line (main's fuller one); Load styles and main's fuller Pathfinder line both kept, main's duplicate short Pathfinder line r
docs/ui-parity.md | 1 | IME row main's, Font menu row next's

i18n/fr.rs: five rows dropped that main's shared table now has (Row, Open Path, Flex Layout, Current Page Number, Rulers).
2026-10-16 03:09:22 +00:00

1001 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The DesignCraft document model (pure data + serde).
//!
//! A [`Document`] holds spreads of pages, parent ("master") spreads, layers, stories, styles,
//! swatches, sections and embedded assets. Spreads, items and stories are `Arc`-shared so an
//! edit clones only what it touches and undo snapshots are O(1).
//!
//! Coordinates are points, y down. Each spread has its own space: pages sit side by side from
//! x = 0 with their tops at y = 0. Items live in spread space via `Item::xf`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod anchored;
pub mod arabic;
pub mod arrow;
pub mod assets;
pub mod attrs;
pub mod build;
pub mod cjk;
pub mod cjk_settings;
pub mod datamerge;
mod edit;
pub mod endnotes;
pub mod flex;
pub mod ids;
pub mod index;
pub mod item;
pub mod mojikumi;
pub mod notes;
pub mod otf;
pub mod page;
pub mod ruby;
pub mod selection;
mod slice;
pub mod story;
pub mod styles;
mod swatch_refs;
pub mod table;
pub mod vars;
pub mod xref;
use std::collections::BTreeMap;
use std::sync::Arc;
pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK};
pub use assets::{find_same_asset, insert_asset, remap_story_assets, story_asset_refs};
pub use attrs::*;
pub use datamerge::{
DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole,
SourceStatus,
};
pub use designcraft_color as color;
pub use designcraft_geom as geom;
pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit};
pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK};
pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize};
pub use ids::*;
pub use index::{INDEX_MARK, IndexRef};
pub use item::*;
pub use notes::{FOOTNOTE_REF, FOOTNOTE_TABLE, Footnote, FootnoteOptions};
pub use page::*;
pub use selection::*;
pub use story::*;
pub use styles::*;
pub use table::*;
pub use xref::{ANCHOR_MARK, CrossRef, TextAnchor, XREF_MARK, XrefFormat};
use designcraft_color::Swatch;
use designcraft_geom::Unit;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DocError {
#[error("no such item {0}")]
NoItem(ItemId),
#[error("no such story {0}")]
NoStory(StoryId),
#[error("no such page {0}")]
NoPage(usize),
#[error("no such layer {0}")]
NoLayer(LayerId),
#[error("{0}")]
Invalid(String),
}
pub type Result<T> = std::result::Result<T, DocError>;
/// Largest `next_id` a valid document has (ids count up from it; 2^53 also stays exact in JSON
/// readers that use doubles).
pub const MAX_NEXT_ID: u64 = 1 << 53;
/// Edit › Transparency Blend Space: the colour space transparency is flattened in (CMYK for
/// print documents, RGB for screen ones, as InDesign defaults).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BlendSpace {
#[default]
Cmyk,
Rgb,
}
/// Preferences › Units & Increments › Origin: what the rulers and X/Y fields measure from
/// (before the zero point moves it).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RulerOrigin {
/// The top-left corner of the spread's pages.
#[default]
Spread,
/// The top-left corner of each page.
Page,
/// The spine, at the top of the spread.
Spine,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Intent {
#[default]
Print,
Web,
Mobile,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GridRelative {
#[default]
TopOfPage,
TopMargin,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct BaselineGrid {
pub start: f64,
pub increment: f64,
pub relative_to: GridRelative,
pub color: [u8; 3],
/// Hidden below this zoom (1.0 = 100%).
pub view_threshold: f64,
}
impl Default for BaselineGrid {
fn default() -> Self {
BaselineGrid { start: 36.0, increment: 12.0, relative_to: GridRelative::TopOfPage, color: [140, 205, 230], view_threshold: 0.75 }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DocumentGrid {
pub horizontal: f64,
pub vertical: f64,
pub subdivisions: u32,
pub color: [u8; 3],
pub in_back: bool,
}
impl Default for DocumentGrid {
fn default() -> Self {
DocumentGrid { horizontal: 72.0, vertical: 72.0, subdivisions: 8, color: [200, 200, 200], in_back: true }
}
}
/// A named numbered list.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NumberedList {
pub name: String,
/// Numbering continues from story to story (in page order).
pub continue_across_stories: bool,
}
/// A conditional-text condition: its indicator colour (screen only) and whether text with it shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Condition {
pub name: String,
pub color: [u8; 3],
pub visible: bool,
}
impl Document {
/// A stroke type with named styles resolved (unknown names draw solid).
pub fn stroke_kind(&self, t: &StrokeType) -> StrokeType {
match t {
StrokeType::Style { name } => self
.stroke_styles
.iter()
.find(|s| s.name == *name)
.map(|s| s.kind.clone())
.filter(|k| !matches!(k, StrokeType::Style { .. }))
.unwrap_or(StrokeType::Solid),
t => t.clone(),
}
}
/// Hyphenation exceptions by lowercase word: the break positions (char indices; empty = never
/// hyphenate).
pub fn hyphenation_exception_map(&self) -> std::collections::HashMap<String, Vec<usize>> {
self.hyphenation_exceptions
.iter()
.map(|e| {
let mut word = String::new();
let mut breaks = Vec::new();
for c in e.trim().chars() {
if c == '~' {
if breaks.last() != Some(&word.chars().count()) && !word.is_empty() {
breaks.push(word.chars().count());
}
} else {
word.extend(c.to_lowercase());
}
}
breaks.retain(|b| *b < word.chars().count());
(word, breaks)
})
.collect()
}
/// Is text with these conditions hidden? (Conditioned text shows while any of its conditions
/// is visible.)
pub fn conditions_hide(&self, conds: &[String]) -> bool {
!conds.is_empty() && conds.iter().all(|c| self.conditions.iter().any(|x| x.name == *c && !x.visible))
}
}
/// Ink Manager settings for output.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct InkManager {
/// All Spots to Process.
pub all_to_process: bool,
/// Spot inks converted to process one by one.
pub to_process: Vec<String>,
/// Ink Alias: (spot ink, the ink it prints on).
pub aliases: Vec<(String, String)>,
}
impl InkManager {
fn is_default(&self) -> bool {
*self == Self::default()
}
/// The ink a spot swatch prints on (after aliases; at most a few hops) and whether it's
/// converted to process.
pub fn resolve<'a>(&'a self, ink: &'a str) -> (&'a str, bool) {
let mut n = ink;
for _ in 0..8 {
match self.aliases.iter().find(|(a, _)| a == n) {
Some((_, to)) if to != n => n = to,
_ => break,
}
}
(n, self.all_to_process || self.to_process.iter().any(|x| x == n))
}
}
/// XML tags and mappings.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct XmlSettings {
pub root: String,
pub tags: Vec<XmlTag>,
/// Paragraph style → tag: tagged frames' paragraphs become elements of that name.
pub style_map: Vec<(String, String)>,
/// The loaded Document Type Definition (its text), for validation.
#[serde(skip_serializing_if = "String::is_empty")]
pub dtd: String,
}
impl XmlSettings {
pub fn is_empty(&self) -> bool {
self.tags.is_empty() && self.style_map.is_empty() && self.root.is_empty() && self.dtd.is_empty()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct XmlTag {
pub name: String,
pub color: [u8; 3],
}
/// An article: objects in reading order; `export` includes it when exporting.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Article {
pub name: String,
pub items: Vec<ItemId>,
#[serde(default = "yes")]
pub export: bool,
}
/// A Swatches panel colour group: a named folder of swatches (by name).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ColorGroup {
pub name: String,
pub swatches: Vec<String>,
}
/// Preferences › Advanced Type (stored with the document, like InDesign).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AdvancedType {
pub superscript_size: f64,
pub superscript_position: f64,
pub subscript_size: f64,
pub subscript_position: f64,
}
impl Default for AdvancedType {
fn default() -> Self {
AdvancedType { superscript_size: 58.3, superscript_position: 33.3, subscript_size: 58.3, subscript_position: 33.3 }
}
}
/// File → Document Setup + the guide/grid/unit preferences stored with the document.
/// Where a document's chapter number comes from (Numbering & Section Options › Document Chapter
/// Numbering). A book's Update Numbering resolves it in book order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChapterSource {
/// Automatic Chapter Numbering: one more than the previous document in the book (1 first).
#[default]
Automatic,
/// Start Chapter Numbering at `chapter_number`.
UserDefined,
/// Same as Previous Document in the Book: the document continues its chapter.
SameAsPrevious,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DocSettings {
pub intent: Intent,
pub page_width: f64,
pub page_height: f64,
pub facing_pages: bool,
/// Binding: Right to Left — page 1 is a left page and spreads read from right to left.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub right_to_left_binding: bool,
pub primary_text_frame: bool,
/// Bleed: top, bottom, inside, outside.
pub bleed: [f64; 4],
pub slug: [f64; 4],
pub horizontal_units: Unit,
pub vertical_units: Unit,
pub baseline_grid: BaselineGrid,
pub grid: DocumentGrid,
/// Pasteboard extent around spreads (horizontal, vertical).
pub pasteboard: (f64, f64),
pub margin_color: [u8; 3],
pub column_color: [u8; 3],
pub bleed_color: [u8; 3],
pub slug_color: [u8; 3],
pub keyboard_increment: f64,
/// Kerning/Tracking increment (Alt+Left/Right in text), in 1/1000 em.
pub kerning_increment: f64,
/// Named lists (Type › Bulleted & Numbered Lists › Define Lists).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lists: Vec<NumberedList>,
/// The story in the primary text frames (Smart Text Reflow adds and removes pages for it).
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_story: Option<StoryId>,
/// Chapter number (Numbering & Section Options › Document Chapter Numbering): the one set by
/// Start Chapter Numbering at, or the one a book last gave the document.
pub chapter_number: u32,
/// The chapter number's style (Chapter Number variables, cross-references).
pub chapter_style: NumberStyle,
/// Where the chapter number comes from.
pub chapter_source: ChapterSource,
/// Object › Effects › Global Light: the angle (degrees) shadows that use it share.
pub global_light: f64,
/// Preferences › Advanced Type: superscript and subscript size and position (percent of the
/// font size).
pub advanced_type: AdvancedType,
/// Edit › Transparency Blend Space.
pub blend_space: BlendSpace,
/// Preferences › Appearance of Black: [Black] at 100% overprints (Overprint Preview, output).
pub overprint_black: bool,
/// Type › Track Changes: edits are recorded as inserted / deleted text.
pub track_changes: bool,
/// Preferences › Composition › Draw Missing Glyphs from Fallback Fonts: characters the
/// applied font lacks are drawn from other fonts. Off (InDesign's behaviour, and new
/// documents'), they are drawn as the font's missing-glyph box and Preflight lists them.
/// Documents saved before the setting existed read as on, the way they were drawn.
#[serde(default = "yes")]
pub glyph_fallback: bool,
/// What the rulers measure from before the zero point moves it.
pub ruler_origin: RulerOrigin,
/// The rulers' zero point: points right of and below the ruler origin (`ruler_origin`).
/// One per document, at the same place on every spread (and page, for a page origin). X/Y
/// fields measure from it.
#[serde(skip_serializing_if = "is_origin")]
pub zero_point: [f64; 2],
/// Lock Zero Point: dragging from the ruler corner leaves it where it is.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub zero_point_locked: bool,
}
fn is_origin(p: &[f64; 2]) -> bool {
p[0] == 0.0 && p[1] == 0.0
}
impl Default for DocSettings {
fn default() -> Self {
DocSettings {
intent: Intent::Print,
page_width: 612.0,
page_height: 792.0,
facing_pages: true,
right_to_left_binding: false,
primary_text_frame: false,
bleed: [0.0; 4],
slug: [0.0; 4],
horizontal_units: Unit::Picas,
vertical_units: Unit::Picas,
baseline_grid: BaselineGrid::default(),
grid: DocumentGrid::default(),
pasteboard: (72.0, 72.0),
margin_color: [255, 56, 255],
column_color: [166, 40, 255],
bleed_color: [255, 72, 103],
slug_color: [100, 188, 221],
keyboard_increment: 1.0,
kerning_increment: 20.0,
primary_story: None,
lists: Vec::new(),
chapter_number: 1,
chapter_style: NumberStyle::Arabic,
chapter_source: ChapterSource::Automatic,
global_light: 120.0,
advanced_type: AdvancedType::default(),
blend_space: BlendSpace::Cmyk,
overprint_black: true,
track_changes: false,
glyph_fallback: false,
ruler_origin: RulerOrigin::Spread,
zero_point: [0.0, 0.0],
zero_point_locked: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Layer {
pub id: LayerId,
pub name: String,
/// Selection / frame-edge colour.
pub color: [u8; 3],
#[serde(default = "yes")]
pub visible: bool,
#[serde(default)]
pub locked: bool,
#[serde(default = "yes")]
pub printable: bool,
#[serde(default = "yes")]
pub show_guides: bool,
#[serde(default)]
pub suppress_wrap_when_hidden: bool,
}
fn yes() -> bool {
true
}
/// InDesign's layer colour sequence (names are the conventional colour names).
pub const LAYER_COLORS: &[(&str, [u8; 3])] = &[
("Light Blue", [43, 155, 255]),
("Red", [255, 0, 0]),
("Green", [79, 255, 79]),
("Blue", [0, 0, 255]),
("Yellow", [255, 255, 79]),
("Magenta", [255, 79, 255]),
("Cyan", [0, 255, 255]),
("Gray", [128, 128, 128]),
("Black", [0, 0, 0]),
("Orange", [255, 102, 0]),
("Dark Green", [0, 84, 0]),
("Teal", [0, 153, 153]),
("Tan", [204, 153, 102]),
("Brown", [153, 51, 0]),
("Violet", [153, 51, 255]),
("Gold", [255, 153, 0]),
];
/// An embedded file (placed image / PDF). Bytes are stored next to the JSON in the native format.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Asset {
pub id: AssetId,
pub name: String,
pub mime: String,
/// Original location on disk (Links panel), if linked.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
#[serde(skip)]
pub data: Arc<Vec<u8>>,
/// Pixel size for raster images.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pixels: Option<(u32, u32)>,
/// The page shown from a multi-page PDF (0-based; Image Import Options).
#[serde(default, skip_serializing_if = "is_zero")]
pub page: u32,
/// Image Import Options › Crop to: the page box a placed PDF shows.
#[serde(default, skip_serializing_if = "PdfCrop::is_crop")]
pub pdf_crop: PdfCrop,
/// Where the `pdf_crop` box sits on the page as rendered (its visible crop box, rotated as it
/// displays): left, top, right, bottom as fractions of the page's width and height. Graphic
/// space (0,0)-(w,h) spans this box. `None`: the whole page, or a Bounding Box crop not
/// measured yet (`designcraft_render::shown_box` measures it when the graphic is drawn).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pdf_box: Option<[f64; 4]>,
}
impl Asset {
/// The part of the page a placed PDF shows, when [`Asset::pdf_box`] holds a usable box.
pub fn shown_box(&self) -> Option<[f64; 4]> {
self.shown(self.pdf_box?)
}
/// `b` as the part of the page this asset shows: a usable box of a placed PDF.
pub fn shown(&self, b: [f64; 4]) -> Option<[f64; 4]> {
// A box under 1/10000 of the page, or far outside it, would blow the page up to sizes
// nothing can draw.
let usable = b.iter().all(|v| v.is_finite() && v.abs() <= 1e3) && b[2] - b[0] >= 1e-4 && b[3] - b[1] >= 1e-4;
(usable && self.mime == "application/pdf").then_some(b)
}
/// A Bounding Box crop ([`PdfCrop::ContentVisible`], [`PdfCrop::ContentAll`]) whose box isn't
/// stored yet: finding it needs the page's drawing interpreted.
pub fn content_box_pending(&self) -> bool {
self.pdf_box.is_none() && matches!(self.pdf_crop, PdfCrop::ContentVisible | PdfCrop::ContentAll) && self.mime == "application/pdf"
}
}
/// Image Import Options › Crop to: which box of a PDF page a placed PDF shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PdfCrop {
/// The crop box: the page as PDF readers show it.
#[default]
Crop,
Art,
Trim,
Bleed,
Media,
/// The bounds of what the page draws, with the layers the PDF shows by default.
ContentVisible,
/// The bounds of what the page draws, with every layer on.
ContentAll,
}
impl PdfCrop {
pub const ALL: [PdfCrop; 7] =
[PdfCrop::ContentVisible, PdfCrop::ContentAll, PdfCrop::Crop, PdfCrop::Art, PdfCrop::Trim, PdfCrop::Bleed, PdfCrop::Media];
/// The name commands and files use (`crop`, `trim`, `contentVisible`…).
pub fn name(self) -> &'static str {
match self {
PdfCrop::Crop => "crop",
PdfCrop::Art => "art",
PdfCrop::Trim => "trim",
PdfCrop::Bleed => "bleed",
PdfCrop::Media => "media",
PdfCrop::ContentVisible => "contentVisible",
PdfCrop::ContentAll => "contentAll",
}
}
pub fn from_name(name: &str) -> Option<PdfCrop> {
PdfCrop::ALL.into_iter().find(|c| c.name() == name)
}
fn is_crop(&self) -> bool {
*self == PdfCrop::Crop
}
}
impl Asset {
/// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG.
pub fn is_raster(&self) -> bool {
matches!(
self.mime.as_str(),
"image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/tiff" | "image/bmp" | "image/vnd.adobe.photoshop"
)
}
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Document {
pub title: String,
pub settings: DocSettings,
pub spreads: Vec<Arc<Spread>>,
pub parents: Vec<Arc<Spread>>,
/// Top of the list = frontmost layer (Layers panel order).
pub layers: Vec<Layer>,
pub stories: BTreeMap<StoryId, Arc<Story>>,
pub styles: Arc<Styles>,
pub swatches: Vec<Swatch>,
/// Swatches panel colour groups (folders), in panel order; swatches not in a group sit at
/// the top level.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub color_groups: Vec<ColorGroup>,
/// XML: the tags (Tags panel) and the paragraph style → tag map (Map Styles to Tags).
#[serde(default, skip_serializing_if = "XmlSettings::is_empty")]
pub xml: XmlSettings,
/// Articles panel: named reading-order lists of objects (EPUB / HTML export order).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub articles: Vec<Article>,
/// Custom stroke styles (Object › Stroke Styles).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stroke_styles: Vec<StrokeStyleDef>,
/// Ink Manager: spot inks printed as process, and spot inks aliased to others.
#[serde(default, skip_serializing_if = "InkManager::is_default")]
pub inks: InkManager,
/// Document Endnote Options, and the story shown in the endnote frame (generated).
#[serde(default)]
pub endnote_options: EndnoteOptions,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endnote_story: Option<StoryId>,
/// Conditional text conditions; text whose conditions are all hidden isn't composed.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conditions: Vec<Condition>,
pub sections: Vec<Section>,
#[serde(default)]
pub assets: BTreeMap<AssetId, Arc<Asset>>,
/// Hyperlinks (Window → Interactive → Hyperlinks).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hyperlinks: Vec<Hyperlink>,
/// Data merge: the linked table, placeholders, and the last merge options.
#[serde(default, skip_serializing_if = "DataMerge::is_empty")]
pub data_merge: DataMerge,
/// PDF bookmarks (Window → Interactive → Bookmarks).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bookmarks: Vec<Bookmark>,
/// Words added to the document's user dictionary (spelling).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub user_words: Vec<String>,
/// User dictionary hyphenation exceptions: `ex~am~ple` (breaks only at `~`), or a word with
/// no `~` that never hyphenates.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hyphenation_exceptions: Vec<String>,
/// The generated table of contents (Layout → Table of Contents), kept for Update.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub toc: Option<Toc>,
/// Text variable definitions (instances in stories are `vars::var_char(index)`).
#[serde(default = "vars::defaults")]
pub text_variables: Vec<vars::TextVariable>,
/// Type › Document Footnote Options.
#[serde(default)]
pub footnote_options: FootnoteOptions,
/// Cross-reference formats (Type › Hyperlinks & Cross-References › Cross-Reference Formats).
#[serde(default = "xref::default_formats")]
pub xref_formats: Vec<XrefFormat>,
/// The generated index (kept for Update Index).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<index::IndexSpec>,
/// Creation / last save time (Unix seconds, UTC).
#[serde(default)]
pub created: i64,
#[serde(default)]
pub modified: i64,
pub next_id: u64,
/// While the document is open, the font scope of the fonts it brought (its `Document Fonts`
/// folder): composition, export and the font menus look its fonts up there. 0: none. Not
/// saved.
#[serde(skip)]
pub font_scope: u32,
}
/// What a hyperlink is attached to.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum HyperlinkSource {
Item { id: ItemId },
Text { story: StoryId, start: usize, end: usize },
}
/// Where a hyperlink goes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum HyperlinkDest {
Url(String),
Email(String),
/// Absolute page index.
Page(usize),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hyperlink {
pub id: u64,
pub name: String,
pub source: HyperlinkSource,
pub dest: HyperlinkDest,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TocEntry {
/// Paragraph style whose paragraphs are listed.
pub style: String,
/// 1-based nesting level.
#[serde(default = "one")]
pub level: u8,
/// Paragraph style of this style's entries; `None` is the generated "TOC Level n" style.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_style: Option<String>,
#[serde(default)]
pub page_number: TocPageNumber,
/// Text between the entry and its page number; `None` is a tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub separator: Option<String>,
}
impl TocEntry {
pub fn new(style: impl Into<String>, level: u8) -> Self {
Self { style: style.into(), level, entry_style: None, page_number: TocPageNumber::After, separator: None }
}
}
/// Where an entry's page number goes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TocPageNumber {
#[default]
After,
Before,
None,
}
fn one() -> u8 {
1
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Toc {
pub story: StoryId,
pub title: String,
pub entries: Vec<TocEntry>,
#[serde(default)]
pub page_numbers: bool,
/// Paragraph style of the title; `None` is the generated "TOC Title" style.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title_style: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Bookmark {
pub name: String,
/// Absolute page index.
pub page: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<Bookmark>,
}
impl Document {
/// Allocate a fresh id.
pub fn alloc(&mut self) -> u64 {
self.next_id += 1;
self.next_id
}
pub fn page_count(&self) -> usize {
self.spreads.iter().map(|s| s.pages.len()).sum()
}
/// (spread index, page index within spread) for an absolute page index.
pub fn page_loc(&self, abs: usize) -> Option<(usize, usize)> {
let mut n = 0;
for (si, s) in self.spreads.iter().enumerate() {
if abs < n + s.pages.len() {
return Some((si, abs - n));
}
n += s.pages.len();
}
None
}
/// Absolute index of the first page of spread `si`.
pub fn first_page_of_spread(&self, si: usize) -> usize {
self.spreads[..si.min(self.spreads.len())].iter().map(|s| s.pages.len()).sum()
}
pub fn page(&self, abs: usize) -> Option<&Page> {
let (s, p) = self.page_loc(abs)?;
self.spreads[s].pages.get(p)
}
/// Iterate all pages with their absolute index and spread index.
pub fn pages(&self) -> impl Iterator<Item = (usize, usize, &Page)> {
self.spreads.iter().enumerate().flat_map(|(si, s)| s.pages.iter().map(move |p| (si, p))).enumerate().map(|(i, (si, p))| (i, si, p))
}
/// The section containing absolute page `abs`.
pub fn section_of(&self, abs: usize) -> Option<&Section> {
self.sections.iter().filter(|s| s.start <= abs).max_by_key(|s| s.start)
}
/// The page number (before formatting) of absolute page `abs`, per sections. A section without
/// a start number continues from the previous section.
/// Saturates at `u32::MAX`, so start numbers loaded from a file can't overflow it.
pub fn page_number(&self, abs: usize) -> u32 {
// Walks back through continuing sections iteratively: one section per step, never recursing.
let (mut abs, mut offset) = (abs, 0u32);
loop {
let Some(sec) = self.section_of(abs) else {
return u32::try_from(abs).unwrap_or(u32::MAX).saturating_add(1).saturating_add(offset);
};
let into = u32::try_from(abs - sec.start).unwrap_or(u32::MAX).saturating_add(offset);
match sec.start_number {
Some(n) => return n.saturating_add(into),
None if sec.start == 0 => return into.saturating_add(1),
None => {
offset = into.saturating_add(1);
abs = sec.start - 1;
}
}
}
}
/// The displayed page name ("1", "iv", "A-3"…) for an absolute page index, per sections.
/// The chapter number in its style (`3`, `III`, `c`, `03`).
pub fn chapter_label(&self) -> String {
self.settings.chapter_style.format(self.settings.chapter_number.max(1))
}
pub fn page_name(&self, abs: usize) -> String {
let Some(sec) = self.section_of(abs) else { return (abs + 1).to_string() };
let num = sec.style.format(self.page_number(abs));
if sec.include_prefix { format!("{}{}", sec.prefix, num) } else { num }
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
self.layers.iter().find(|l| l.id == id)
}
pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> {
self.layers.iter_mut().find(|l| l.id == id)
}
/// Stacking rank of a layer: 0 = backmost.
pub fn layer_rank(&self, id: LayerId) -> usize {
self.layers.iter().position(|l| l.id == id).map(|i| self.layers.len() - 1 - i).unwrap_or(0)
}
pub fn default_layer(&self) -> LayerId {
self.layers.first().map(|l| l.id).unwrap_or_default()
}
pub fn story(&self, id: StoryId) -> Option<&Story> {
self.stories.get(&id).map(|s| &**s)
}
pub fn story_mut(&mut self, id: StoryId) -> Option<&mut Story> {
self.stories.get_mut(&id).map(Arc::make_mut)
}
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
self.swatches.iter().find(|s| s.name == name)
}
/// Resolve a swatch reference to a display colour.
/// A shadow's light angle: its own, or the document's Global Light.
pub fn light_angle(&self, angle: f64, global: bool) -> f64 {
if global { self.settings.global_light } else { angle }
}
pub fn resolve_color(&self, swatch: &str, tint: f32) -> Option<designcraft_color::Color> {
designcraft_color::swatch::resolve(&self.swatches, swatch, tint)
}
/// Validate structural invariants (ids unique, threads consistent, stories well-formed).
pub fn check(&self) -> Result<()> {
for f in &self.styles.composite_fonts {
if f.entries.iter().any(|e| {
![e.relative_size, e.horizontal_scale, e.vertical_scale].iter().all(|v| v.is_finite() && *v > 0.0) || !e.baseline_shift.is_finite()
}) {
return Err(DocError::Invalid(format!("invalid composite font metrics: {}", f.name)));
}
}
for t in &self.styles.mojikumi_tables {
if t.overrides.iter().any(|r| ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite())) {
return Err(DocError::Invalid(format!("non-finite mojikumi spacing: {}", t.name)));
}
}
let mut ids = std::collections::HashSet::new();
for sp in self.spreads.iter().chain(self.parents.iter()) {
for it in &sp.items {
let mut dup = None;
it.walk(&mut |i| {
if !ids.insert(i.id.0) {
dup = Some(i.id);
}
});
if let Some(d) = dup {
return Err(DocError::Invalid(format!("duplicate item id {d}")));
}
}
}
for (sid, st) in &self.stories {
st.check().map_err(DocError::Invalid)?;
if st.id != *sid {
return Err(DocError::Invalid(format!("story key {sid} != id {}", st.id)));
}
for f in &st.frames {
let item = self.item(*f).or_else(|| self.anchored_item(*f)).ok_or(DocError::NoItem(*f))?;
match item.text_frame().map(|t| t.story) {
Some(s) if s == *sid => {}
other => return Err(DocError::Invalid(format!("frame {f} in story {sid} points at {other:?}"))),
}
}
}
// Every text frame is in its story's thread.
for sp in self.spreads.iter().chain(self.parents.iter()) {
for it in &sp.items {
let mut bad = None;
it.walk(&mut |i| {
if let Some(t) = i.text_frame()
&& !self.stories.get(&t.story).is_some_and(|s| s.frames.contains(&i.id))
{
bad = Some(i.id);
}
});
if let Some(b) = bad {
return Err(DocError::Invalid(format!("text frame {b} not in its story's thread")));
}
}
}
let embedded = self.anchored_items();
let mut edges: std::collections::HashMap<StoryId, Vec<StoryId>> = std::collections::HashMap::new();
for (owner, item) in embedded {
if !ids.insert(item.id.0) {
return Err(DocError::Invalid(format!("duplicate item id {}", item.id)));
}
if let Some(tf) = item.text_frame() {
if !self.stories.get(&tf.story).is_some_and(|s| s.frames.contains(&item.id)) {
return Err(DocError::Invalid(format!("embedded text frame {} not in its story's thread", item.id)));
}
edges.entry(owner).or_default().push(tf.story);
}
}
// Reject cycles and excessive nesting before renderers follow embedded frames.
fn visit(
id: StoryId,
edges: &std::collections::HashMap<StoryId, Vec<StoryId>>,
path: &mut Vec<StoryId>,
done: &mut std::collections::HashMap<StoryId, usize>,
) -> Result<usize> {
if path.contains(&id) || path.len() >= 64 {
return Err(DocError::Invalid("cyclic or excessively nested anchored text frames".into()));
}
if let Some(&height) = done.get(&id) {
return Ok(height);
}
path.push(id);
let mut height = 1;
if let Some(children) = edges.get(&id) {
for &child in children {
height = height.max(1 + visit(child, edges, path, done)?);
}
}
path.pop();
if height > 64 {
return Err(DocError::Invalid("excessively nested anchored text frames".into()));
}
done.insert(id, height);
Ok(height)
}
let mut done = std::collections::HashMap::new();
for &id in edges.keys() {
visit(id, &edges, &mut Vec::new(), &mut done)?;
}
if self.next_id < ids.iter().copied().max().unwrap_or(0) {
return Err(DocError::Invalid("next_id behind existing ids".into()));
}
// Ids are allocated by counting up from `next_id`: leave room so that can't overflow.
if self.next_id > MAX_NEXT_ID {
return Err(DocError::Invalid(format!("next_id {} out of range", self.next_id)));
}
Ok(())
}
}
#[cfg(test)]
mod tests;
impl Document {
/// Mutable styles (copy-on-write).
pub fn styles_mut(&mut self) -> &mut Styles {
Arc::make_mut(&mut self.styles)
}
}