Merge storytold/main into PR #1990

# Conflicts:
#	docs/typography-parity.md
This commit is contained in:
Brandon Thomas
2026-10-29 21:53:38 -04:00
53 changed files with 2373 additions and 92 deletions
+1
View File
@@ -8,6 +8,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| German hyphenation patterns (embedded by `hypher` 0.1.8, feature `german`) | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr | https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex | MIT (copyright 2013–2024; full text in NOTICE) | Version 2024-02-28; reformed German spelling (2006), despite the `de-1996` filename; no separate old-spelling or Swiss patterns |
| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts |
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts |
| `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below |
+16
View File
@@ -74,6 +74,22 @@ list (author, source, license) is in ASSETS.md.
with or without modification, is permitted in any medium without royalty provided the copyright
notice and this notice are preserved (additionally distributed under the LaTeX Project Public
License in CTAN distributions).
- German hyphenation patterns, embedded through the hypher crate (feature "german"):
hyph-de-1996.tex 2024-02-28, German reformed orthography (2006), from hyph-utf8,
https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex.
Copyright (c) 2013-2024 Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven,
Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr. MIT License:
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.
- Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official
releases) embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
+10 -1
View File
@@ -12,6 +12,10 @@
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
- **2026-10-28** · Inline table rows leave room for column and spanning footnotes, continuing in
the next column or frame when needed (#1901). Cell-local footnotes contribute to row height
and sit inside the cell rather than outside its measuring frame. Vertical justification keeps
paragraphs and table fragments above the footnote area. No readiness percentage change.
- **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that
starts on a floating panel over the bar moves the panel, not the window, and in full screen the
bar no longer zooms or moves the window. No readiness percentage change.
@@ -127,7 +131,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). English, Spanish, Portuguese, Norwegian, Greek and German (2006 reform) hyphenate. German uses `hyph-de-1996.tex` 2024-02-28 (MIT) through `hypher`; the filename denotes the reformed 2006 rules. Other languages have no bundled patterns.
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
@@ -381,6 +385,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change.
- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change.
- **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change.
- **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change.
- **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change.
@@ -412,6 +417,7 @@ Newest first.
- **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change.
- **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change.
- **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change.
- **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change.
- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
- **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change.
- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
@@ -421,6 +427,7 @@ Newest first.
- **2026-10-23** · File › File Info… edits the document's Document Title, Author, Description, Keywords, Copyright Notice and Copyright Info URL (`file.info`), and the document saves them. PDF export (PDF/X-4 and Print Booklet included) writes the title, author, description and keywords into the PDF's document information and XMP. EPUB export takes the title and author from File Info, and HTML export takes the title. A title or author given to the export replaces File Info's, and an empty Document Title leaves the document's name as the title. No readiness percentage change.
- **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change.
- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change.
- **2026-10-18** · German text hyphenates using the MIT-licensed 2006 reform patterns (`hyph-de-1996.tex`) through `hypher`. Explicit 2006 names and plain/2006 locale codes reach both composers; traditional and unknown spelling variants stay unhyphenated. Tests cover this distinction, umlauts, ß, compounds, reformed ck/st breaks and paragraph limits. Six of InDesign's ~40 hyphenation languages. No readiness percentage change.
- **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-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.
- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change.
@@ -432,6 +439,7 @@ 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-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change.
- **2026-10-19** · A PDF export can repeat its bytes: it recorded the second it was made in, so exporting one document twice gave different files though the pages matched. `SOURCE_DATE_EPOCH` stands in for the clock when the export is given no date, as the reproducible-builds specification defines it; a date passed in still wins, and a malformed value is ignored rather than refusing the export. The timestamp was the only thing that differed, so the files are now identical byte for byte. No readiness percentage change.
- **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded
font program, including placed PDF forms; `validate-pdf` reports the font and page (#909).
Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically.
@@ -449,6 +457,7 @@ Newest first.
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
- **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. 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.
+8 -1
View File
@@ -96,7 +96,9 @@ pub fn value_name(c: Color) -> String {
Color::Cmyk { c, m, y, k } => cmyk_name(c, m, y, k),
other => {
let [r, g, b] = other.to_rgb();
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
// `+ 0.0` turns -0.0 into 0.0 so it prints as "0", not "-0".
let p = |v: f32| (v * 255.0).round() + 0.0;
format!("R={} G={} B={}", p(r), p(g), p(b))
}
}
}
@@ -172,6 +174,11 @@ pub fn default_swatches() -> Vec<Swatch> {
mod tests {
use super::*;
#[test]
fn value_name_has_no_negative_zero() {
assert_eq!(value_name(Color::rgb(-0.001, 0.5, 1.0)), "R=0 G=128 B=255");
}
#[test]
fn defaults_start_with_specials() {
let s = default_swatches();
+1 -1
View File
@@ -21,7 +21,7 @@ log = { workspace = true }
regex = { workspace = true }
fancy-regex = { workspace = true }
miniz_oxide = "0.8"
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "greek", "portuguese", "spanish", "norwegian"] }
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "german", "greek", "portuguese", "spanish", "norwegian"] }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
+85
View File
@@ -6,6 +6,10 @@
//! Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken
//! under MIT. Those are the **monotonic** patterns, which is what `hypher` maps `el` to, so
//! polytonic text hyphenates by them as well.
//! German uses `hyph-de-1996.tex` (2024-02-28, Deutschsprachige Trennmustermannschaft, MIT):
//! despite the filename, these are the reformed 2006 spelling patterns. Only the two 2006 reform
//! names and German locale codes without a reform year or with 2006 use this set. Traditional,
//! 1901 and unknown German variants have no automatic hyphenation; no old-spelling set is bundled.
//!
//! English lookup order for each word:
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
@@ -84,6 +88,11 @@ impl Lang {
}
let tag = designcraft_doc::language_tag(language)?;
let sub = designcraft_doc::language_subtag(tag);
// language_tag deliberately drops spelling variants for shaping and quotes. Hyphenation
// must retain that distinction: the 2006 patterns are wrong for traditional ck/st breaks.
if sub == "de" && !german_2006_language(language) {
return None;
}
if sub == "en" {
return Some(Lang::English);
}
@@ -95,6 +104,15 @@ impl Lang {
}
}
/// Accept explicit reform names and unqualified German locales (our modern default), but never
/// silently substitute reform patterns for another spelling variant imported from IDML.
fn german_2006_language(language: &str) -> bool {
let language = language.trim();
let normalized = language.strip_prefix("$ID/").unwrap_or(language).to_lowercase();
let words: Vec<&str> = normalized.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
matches!(words.as_slice(), ["german", "2006", "reform"] | ["german", "swiss", "2006", "reform"] | ["de"] | ["de", _] | ["de", _, "2006"])
}
/// Where a word's break points came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
@@ -379,6 +397,73 @@ mod tests {
assert!(hyphen_points("typography", &lim).is_empty());
}
#[test]
fn german_2006_uses_reformed_patterns() {
let de = Lang::for_language("German: 2006 Reform").unwrap();
assert_eq!(de, Lang::Patterns(hypher::Lang::German));
for name in [
"$ID/German: 2006 Reform",
" german: 2006 reform ",
"de",
"de-DE",
"de_AT",
"de_CH",
"de_DE_2006",
"German: Swiss 2006 Reform",
"$ID/German: Swiss 2006 Reform",
"de_CH_2006",
] {
assert_eq!(Lang::for_language(name), Some(de), "{name}");
}
for (word, expected) in [
("Silbentrennung", "Sil-ben-tren-nung"),
("Zucker", "Zu-cker"),
("Bäcker", "Bä-cker"),
("Fenster", "Fens-ter"),
("Straße", "Stra-ße"),
("Fußgänger", "Fuß-gän-ger"),
("Donaudampfschifffahrt", "Do-nau-dampf-schiff-fahrt"),
("SILBENTRENNUNG", "SIL-BEN-TREN-NUNG"),
] {
let pts = hyphen_points_in(word, &Limits::default(), de);
let actual: String = word.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect();
assert_eq!(actual, expected, "{word}");
}
}
#[test]
fn non_2006_german_has_no_bundled_hyphenation() {
for name in [
"German",
"German: Traditional",
"$ID/German: Traditional",
"German: Swiss",
"German: 1901",
"German: 1996 Reform",
"German: Unknown",
"German: Traditional 2006 Reform",
"de_DE_1901",
"de-CH-1901",
"de_AT_1996",
"de_DE_2099",
] {
assert_eq!(designcraft_doc::language_subtag(designcraft_doc::language_tag(name).unwrap()), "de", "{name}");
assert_eq!(Lang::for_language(name), None, "{name}: do not use 2006 ck/st breaks for unsupported spelling variants");
}
}
#[test]
fn german_2006_respects_paragraph_limits() {
let de = Lang::for_language("de_DE_2006").unwrap();
let lim = Limits { after_first: 4, before_last: 5, ..Limits::default() };
assert_eq!(hyphen_points_in("Silbentrennung", &lim, de), vec![6]);
let lim = Limits { min_word: 15, ..Limits::default() };
assert!(hyphen_points_in("Silbentrennung", &lim, de).is_empty());
let lim = Limits { capitalized: false, ..Limits::default() };
assert!(hyphen_points_in("Silbentrennung", &lim, de).is_empty());
assert!(!hyphen_points_in("silbentrennung", &lim, de).is_empty());
}
#[test]
fn spanish_uses_the_bezos_patterns() {
let es = Lang::for_language("Spanish").unwrap();
+11 -4
View File
@@ -1099,7 +1099,7 @@ fn place_table_paragraph(input: TableParagraphInput<'_>) -> TableParagraphResult
}
let at_top = cursor.last_baseline.is_none();
let start_at = (cursor.fi, cursor.col);
if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, opts) {
if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, notes, opts) {
if let Some(j) = trial_failed(trial, limits, *balance_runs < balance_budget) {
*balance_runs += 1;
rewind(j, snaps, note_snaps, notes, out, cursor, list_counter, force_col, line_cap, no_hyph);
@@ -3108,7 +3108,7 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory {
let before: Vec<f64> = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect();
let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true))
|| ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true));
vertical_justify(ft, f, spanned);
vertical_justify(ft, f, spanned, &doc.footnote_options);
for (t, b) in ft.tables.iter_mut().zip(before) {
let now = ft.lines.get(t.line).map_or(b, |l| l.baseline);
t.shift(now - b);
@@ -5320,7 +5320,7 @@ fn hyphenation_points(
}
/// `spanned`: a paragraph spans the frame's columns, so the lines move together.
fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) {
fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) {
if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top {
return;
}
@@ -5333,7 +5333,14 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) {
let idx: Vec<usize> = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect();
let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue };
let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max);
let space = f.area.y1 - bottom;
let bottom = ft.tables.iter().filter(|t| spanned || t.column == c).map(|t| t.rect.y1).fold(bottom, f64::max);
let limit = ft
.notes
.iter()
.filter(|n| spanned || notes.span_columns || n.column == c)
.map(|n| n.rect.y0 - notes.space_before)
.fold(f.area.y1, f64::min);
let space = limit - bottom;
if space <= 0.0 {
continue;
}
+24 -5
View File
@@ -122,7 +122,7 @@ impl CellLineExtent {
/// Compose a cell story at width `w` (content space, top-left at the origin).
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout {
let spec = FrameSpec {
let mut spec = FrameSpec {
id: ItemId(0),
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() },
@@ -137,7 +137,15 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef
right_page,
page_rect: None,
};
let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
let mut cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
// The measuring frame is deliberately tall. Recompose at the actual content height so
// cell-local footnotes sit inside the row rather than at the measuring frame's bottom.
if let Some(frame) = cs.frames.first().filter(|f| !f.notes.is_empty()) {
let body = frame.lines.iter().map(|l| l.baseline + l.descent).fold(0.0, f64::max);
let note_top = frame.notes.first().map_or(spec.area.y1, |n| n.rect.y0);
spec.area.y1 = body + doc.footnote_options.space_before + (spec.area.y1 - note_top);
cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
}
let line_heights: Vec<f64> = cs
.frames
.first()
@@ -157,7 +165,8 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef
.unwrap_or_default();
// Check all lines: a large descender or lowered inline object on an earlier line can
// extend below the final baseline when leading is fixed.
let content_h = line_heights.iter().copied().fold(0.0, f64::max);
let note_bottom = cs.frames.first().map_or(0.0, |f| f.notes.iter().map(|n| n.rect.y1).fold(0.0, f64::max));
let content_h = line_heights.iter().copied().fold(note_bottom, f64::max);
CellLayout { text: Arc::new(cs), content_h, line_heights }
}
@@ -174,6 +183,7 @@ pub(crate) fn place_table(
cols: &[Vec<Rect>],
cur: &mut Cursor,
out: &mut ComposedStory,
notes: &mut crate::notes::Notes,
opts: &ComposeOptions,
) -> bool {
if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 {
@@ -292,6 +302,13 @@ pub(crate) fn place_table(
}
};
place_rows(0..header, &mut y, false, &mut frag_rows);
// Table rows do not add story footnotes; cell-local notes are composed separately.
let column_reserve = |notes: &mut crate::notes::Notes, cur: &Cursor, col: Rect| {
let note_w = notes.area(&cols[cur.fi], cur.col).map_or(col.width(), |a| a.width());
let column = crate::notes::NoteColumn { frame_index: cur.fi, column_index: cur.col, width: note_w, frame: &frames[cur.fi] };
notes.reserve(doc, column, &[], opts)
};
let mut reserve = column_reserve(notes, cur, col);
let mut r = header;
loop {
if r >= body_end {
@@ -299,9 +316,10 @@ pub(crate) fn place_table(
}
let ge = group_end[r].min(body_end).max(r + 1);
let gh = sum(r, ge);
let bottom = col.y1 - reserve;
let at_col_top = cur.last_baseline.is_none() && (top - col.y0).abs() < 1e-6;
if y + gh + footer_h <= col.y1 + 0.01 || (body_placed == 0 && at_col_top) {
if y + gh + footer_h > col.y1 + 0.01 && body_placed == 0 && at_col_top && y + gh > col.y1 + 0.01 {
if y + gh + footer_h <= bottom + 0.01 || (body_placed == 0 && at_col_top && reserve == 0.0) {
if y + gh + footer_h > bottom + 0.01 && body_placed == 0 && at_col_top && y + gh > bottom + 0.01 {
// A row group taller than an empty column can never fit.
return false;
}
@@ -324,6 +342,7 @@ pub(crate) fn place_table(
return false;
}
col = col_rect(cur);
reserve = column_reserve(notes, cur, col);
y = col.y0;
top = y;
frag_rows.clear();
+240
View File
@@ -2068,6 +2068,178 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() {
assert!(note_caret(&cs, n.id, 0).is_some());
}
fn justified_content_clears_footnotes(mode: designcraft_doc::VerticalJustification, table: bool) {
for span in [false, true] {
let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 240.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
d.footnote_options.span_columns = span;
d.footnote_options.space_before = 60.0;
let st = d.story_mut(sid).unwrap();
st.insert_note(5, "Synthetic footnote.", ParaFormat::default());
if table {
let mut t = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0);
t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly;
t.rows[0].height = 90.0;
st.insert_table(st.len(), t);
}
let top = compose_story(&d, sid, &ComposeOptions::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = mode;
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let ft = cs.frame(fid).unwrap();
let original = top.frame(fid).unwrap();
assert_eq!(ft.notes.len(), 1);
assert_eq!(ft.notes[0].rect, original.notes[0].rect, "footnotes must not move");
let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before;
assert!(ft.lines.iter().all(|l| l.baseline + l.descent <= limit + 0.01), "lines overlap footnotes in {mode:?}, span {span}");
assert!(ft.tables.iter().flat_map(|t| &t.cells).all(|c| c.rect.y1 <= limit + 0.01), "cells overlap footnotes in {mode:?}, span {span}");
assert!(ft.lines.last().unwrap().baseline > original.lines.last().unwrap().baseline + 0.01, "content must still move down");
if table {
assert_eq!(ft.tables.len(), 1);
assert!(ft.tables[0].rect.y1 > original.tables[0].rect.y1 + 0.01, "table must still move down");
}
}
}
#[test]
fn bottom_justified_paragraphs_clear_footnotes() {
justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, false);
}
#[test]
fn bottom_justified_tables_clear_footnotes() {
justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, true);
}
#[test]
fn center_justified_tables_clear_footnotes() {
justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Center, true);
}
#[test]
fn vertically_justified_tables_clear_footnotes() {
justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Justify, true);
}
#[test]
fn vertical_justification_counts_table_fragment_bottoms() {
use designcraft_doc::VerticalJustification::{Bottom, Center, Justify};
let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 200.0, 240.0), ParaAttrs::default());
let st = d.story_mut(sid).unwrap();
st.insert_note(5, "Synthetic footnote.", ParaFormat::default());
let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0);
table.rows[0].mode = designcraft_doc::RowHeightMode::Exactly;
table.rows[0].height = 120.0;
st.insert_table(st.len(), table);
let cs = compose_story(&d, sid, &ComposeOptions::default());
for mode in [Bottom, Center, Justify] {
let mut ft = cs.frame(fid).unwrap().clone();
let t = &ft.tables[0];
let (anchor, bottom, baseline) = (t.line, t.rect.y1, t.rect.y0);
// Exercise a fragment extending below its anchor, without a later text line hiding it.
ft.lines.truncate(anchor + 1);
ft.lines[anchor].baseline = baseline;
let mut spec = frame_specs(&d, sid).remove(0);
spec.opts.vertical_justification = mode;
vertical_justify(&mut ft, &spec, false, &d.footnote_options);
let moved = ft.lines[anchor].baseline - baseline;
let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before;
assert!(moved > 0.01);
assert!(bottom + moved <= limit + 0.01, "fragment overlaps footnotes in {mode:?}");
}
}
#[test]
fn table_rows_clear_footnotes_and_continue_in_the_next_column() {
for span in [false, true] {
for row_height in [24.0, 150.0] {
let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
d.footnote_options.span_columns = span;
let st = d.story_mut(sid).unwrap();
st.insert_note(5, "First synthetic footnote.", ParaFormat::default());
st.insert_note(25, "Second synthetic footnote.", ParaFormat::default());
let mut table = designcraft_doc::Table::new(77, 8, 1, 0, 0, 180.0);
for row in &mut table.rows {
row.mode = designcraft_doc::RowHeightMode::Exactly;
row.height = row_height;
}
st.insert_table(st.len(), table);
let (next, _) =
d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 220.0, 420.0, 1720.0), d.default_layer(), "", ParaFormat::default()).unwrap();
d.thread(fid, next).unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let ft = cs.frame(fid).unwrap();
assert_eq!(ft.notes.len(), 2);
let top = ft.notes[0].rect.y0 - d.footnote_options.space_before;
for frag in &ft.tables {
if span || frag.column == 0 {
assert!(frag.cells.iter().all(|c| c.rect.y1 <= top + 0.01), "rows overlap footnotes: {:?}, top {top}", frag.rect);
}
}
assert!(cs.frames.iter().skip(1).any(|f| !f.tables.is_empty()) || ft.tables.iter().any(|t| t.column == 1));
let rows: Vec<_> = cs.frames.iter().flat_map(|f| &f.tables).flat_map(|t| &t.cells).filter(|c| !c.repeated).map(|c| c.row).collect();
assert_eq!(rows, (0..8).collect::<Vec<_>>());
if row_height == 150.0 {
assert!(!ft.tables.iter().any(|t| t.column == 0), "the whole table moves when no row fits");
}
}
}
}
#[test]
fn table_reports_overset_when_every_remaining_column_has_footnotes() {
let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3;
d.footnote_options.span_columns = true;
d.footnote_options.space_before = 40.0;
let st = d.story_mut(sid).unwrap();
st.insert_note(5, "Synthetic spanning footnote.", ParaFormat::default());
let mut table = designcraft_doc::Table::new(77, 2, 1, 0, 0, 100.0);
for row in &mut table.rows {
row.mode = designcraft_doc::RowHeightMode::Exactly;
row.height = 90.0;
}
table.cells[0].row_span = 2;
let anchor = st.insert_table(st.len(), table);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(cs.is_overset());
assert_eq!(cs.overset_at, Some(anchor));
let ft = cs.frame(fid).unwrap();
assert_eq!(ft.notes.len(), 1);
assert!(ft.notes[0].rect.y0 - d.footnote_options.space_before < 180.0);
assert!(cs.frames.iter().all(|f| f.tables.is_empty()), "the unplaced row group must remain overset");
}
#[test]
fn table_cell_footnotes_fit_inside_the_row() {
let (mut d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 200.0, 200.0), ParaAttrs::default());
let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0);
let cell = &mut table.cells[0];
cell.text.insert(0, "Cell reference.");
cell.text.insert_note(5, "Synthetic cell footnote.", ParaFormat::default());
d.story_mut(sid).unwrap().insert_table(0, table);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let cell = &cs.frames[0].tables[0].cells[0];
assert!(!cell.overset);
let frame = &cell.text.frames[0];
assert_eq!(frame.notes.len(), 1);
let note = &frame.notes[0];
assert!(cell.origin.y + note.rect.y1 <= cell.clip.y1 + 0.01, "cell footnote must remain within the row");
assert!(frame.lines.iter().all(|l| l.baseline + l.descent <= note.rect.y0 - d.footnote_options.space_before + 0.01));
}
#[test]
fn footnotes_stack_baseline_to_baseline() {
// Single-line footnotes read as one text: consecutive baselines sit one leading (plus
@@ -4150,6 +4322,36 @@ fn empty_paragraph_uses_its_own_mark_not_the_previous_one() {
assert!((lines[1].baseline - lines[0].baseline - 8.0).abs() < 1e-6, "{} {}", lines[0].baseline, lines[1].baseline);
}
#[test]
fn german_2006_text_gets_german_hyphenation() {
let text = "Silbentrennung Fußgänger Bäcker Fenster Donaudampfschifffahrt Silbentrennung";
let de = hyphen::Lang::for_language("German: 2006 Reform").unwrap();
for composer in [Composer::Paragraph, Composer::SingleLine] {
for language in ["German: 2006 Reform", "de_DE_2006"] {
let para = ParaAttrs { composer: Some(composer), ..Default::default() };
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 96.0, 700.0), para);
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.language = Some(language.into()));
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset(), "{composer:?}, {language}");
let lines = all_lines(&cs);
assert!(lines.iter().any(|l| l.hyphenated), "{composer:?}, {language}: German text hyphenates");
for line in lines.iter().filter(|l| l.hyphenated) {
// Check the actual character offset in the original word, including when the
// word spans more than two lines; UTF-8 byte offsets must not become char counts.
let end = line.range.end;
let start = text[..end].rfind(' ').map_or(0, |at| at + 1);
let word = text[start..].split_whitespace().next().unwrap();
let point = text[start..end].chars().count();
assert!(hyphen::hyphen_points_in(word, &hyphen::Limits::default(), de).contains(&point), "{word} at {point}");
}
// The existing paragraph switch must still disable automatic German hyphenation.
d.story_mut(sid).unwrap().format_paras(0..text.len(), |f| f.para.hyphenate = Some(false));
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(all_lines(&cs).iter().all(|l| !l.hyphenated));
}
}
}
#[test]
fn spanish_text_gets_spanish_hyphenation() {
let text = "La transición democrática fue una construcción colectiva extraordinariamente compleja y desesperadamente necesaria.";
@@ -7229,6 +7431,44 @@ fn anchored_object_wraps_its_own_storys_later_lines() {
assert!((o.origin.y - top).abs() < 1e-6);
}
/// Each object's wrap pushes the anchors after it down, so where each object lands depends on the
/// wraps of all those before it; every object still ends up wrapping the lines it hangs over.
#[test]
fn chained_anchored_objects_all_wrap_their_own_storys_lines() {
const OBJECTS: usize = 8;
let text = [LOREM; 12].join(" ");
let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 3000.0), ParaAttrs::default());
let plain = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&plain);
assert!(lines.len() > 2 + 6 * OBJECTS);
let left = lines[0].x0;
// One object every six lines, each hanging from its anchor's baseline over the lines below;
// inserted last first so the earlier offsets still hold.
let ats: Vec<usize> = (0..OBJECTS).map(|k| lines[2 + 6 * k].range.start + 3).collect();
for &at in ats.iter().rev() {
anchor_box(&mut d, sid, at, 0.0, 0.0, true, WrapMode::BoundingBox);
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let lines = all_lines(&cs);
let anchors: Vec<usize> = d.story(sid).unwrap().text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect();
let objects = &cs.frames[0].objects;
assert_eq!(objects.len(), OBJECTS);
for o in objects {
let anchor_line = lines.iter().find(|l| l.range.contains(&anchors[o.index])).unwrap();
assert!((o.origin.y - anchor_line.baseline).abs() < 1e-6, "object {} at {}, anchor line at {}", o.index, o.origin.y, anchor_line.baseline);
let under = lines_in_band(&cs, o.origin.y, o.origin.y + 60.0);
assert!(under.len() >= 2);
assert!(
under.iter().all(|l| l.x0 >= left + 100.0 - 1e-6),
"object {} at {}: {:?}",
o.index,
o.origin.y,
under.iter().map(|l| (l.baseline, l.x0)).collect::<Vec<_>>()
);
}
}
#[test]
fn anchored_object_without_wrap_leaves_layout_unchanged() {
let long = [LOREM; 3].join(" ");
+32 -6
View File
@@ -53,6 +53,32 @@ pub(crate) fn place(doc: &Document, cs: &ComposedStory, pos: usize) -> Option<(u
Some((doc.first_page_of_spread(si) + pi, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64))
}
/// A paragraph or run a running header may show: where it starts ([`place`]), its story and byte
/// there, and its text.
struct Hit {
at: (usize, i64, i64),
story: StoryId,
pos: usize,
text: String,
}
/// (page, text) in the order running headers read a page: text order within a story (a story's
/// second column comes after its first, however high its entries sit), and stories by where their
/// first entry on the page is (top to bottom, then left to right).
fn in_page_order(hits: Vec<Hit>) -> Vec<(usize, String)> {
// Each story on a page is placed by its first hit there in text order (not its highest hit,
// which in a later column could put the whole story ahead of another).
let mut first: HashMap<(usize, StoryId), (usize, (i64, i64))> = HashMap::new();
for h in &hits {
let (page, y, x) = h.at;
first.entry((page, h.story)).and_modify(|f| *f = (*f).min((h.pos, (y, x)))).or_insert((h.pos, (y, x)));
}
let mut hits: Vec<_> =
hits.into_iter().map(|h| (h.at.0, first.get(&(h.at.0, h.story)).map_or((h.at.1, h.at.2), |f| f.1), h.story, h.pos, h.text)).collect();
hits.sort_by_key(|a| (a.0, a.1, a.2.0, a.3));
hits.into_iter().map(|(page, _, _, _, text)| (page, text)).collect()
}
impl RunningIndex {
/// Index the paragraphs / runs that running-header variables refer to. `get` composes a story
/// (normally through the cache); stories that themselves contain running headers are skipped.
@@ -65,7 +91,8 @@ impl RunningIndex {
_ => None,
})
.collect();
let mut hits: HashMap<(String, bool), Vec<((usize, i64, i64), String)>> = HashMap::new();
// (place, story, byte) of each paragraph or run, with its text.
let mut hits: HashMap<(String, bool), Vec<Hit>> = HashMap::new();
if wanted.is_empty() {
return RunningIndex::default();
}
@@ -90,7 +117,7 @@ impl RunningIndex {
continue;
}
if let Some(at) = place(doc, &cs, r.start) {
hits.entry(key).or_default().push((at, text));
hits.entry(key).or_default().push(Hit { at, story: *sid, pos: r.start, text });
}
}
}
@@ -105,16 +132,15 @@ impl RunningIndex {
continue;
}
if let Some(at) = place(doc, &cs, r.start) {
hits.entry(key).or_default().push((at, text));
hits.entry(key).or_default().push(Hit { at, story: *sid, pos: r.start, text });
}
}
}
}
let mut map = HashMap::new();
for (key, mut v) in hits {
v.sort_by_key(|(at, _)| *at);
for (key, v) in hits {
let mut pages: BTreeMap<usize, (String, String)> = BTreeMap::new();
for ((page, ..), text) in v {
for (page, text) in in_page_order(v) {
pages.entry(page).and_modify(|e| e.1 = text.clone()).or_insert((text.clone(), text));
}
map.insert(key, pages);
+17 -5
View File
@@ -12,16 +12,27 @@
//! that come after the line holding its anchor; that line and the lines before it are never
//! wrapped by it (an inline object sits in its line and wraps none of its own story). The story
//! is composed again with the objects where the previous pass placed them until they stop
//! moving, at most [`MAX_PASSES`] more times.
//! moving. An object's wrap can push the anchors of the objects after it, so a chain of n such
//! objects needs n + 1 more compositions: the k-th pass places the (k + 1)-th object where the
//! wraps before it leave its anchor, and the last pass wraps around the last object. The story
//! is composed at most that many more times (at least [`MIN_PASSES`]), and never more than
//! [`MAX_PASSES`].
use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode};
use designcraft_geom::Affine;
use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec};
/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving
/// its objects (keep options, vertical justification) stops here.
pub(crate) const MAX_PASSES: usize = 4;
/// Most extra compositions of a story with its own anchored objects' wrap, however many objects
/// it has; a layout that keeps moving its objects (keep options, vertical justification) stops
/// sooner, after one per object and one more (but never fewer than [`MIN_PASSES`]).
pub(crate) const MAX_PASSES: usize = 64;
/// Fewest extra compositions allowed before giving up on a layout that keeps moving: a single
/// object that keep options or vertical justification move still gets the room it had before.
const MIN_PASSES: usize = 4;
// `clamp` needs min <= max.
const _: () = assert!(MIN_PASSES <= MAX_PASSES);
/// How far an obstacle may move between passes and still count as settled (points).
const SETTLED: f64 = 0.01;
@@ -199,8 +210,9 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions,
if !has_wrapping_objects(st) {
return out;
}
let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count();
let mut prev = vec![Vec::new(); specs.len()];
for _ in 0..MAX_PASSES {
for _ in 0..(own + 1).clamp(MIN_PASSES, MAX_PASSES) {
let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true));
if settled(&added, &prev) {
break;
+85
View File
@@ -955,6 +955,91 @@ impl Document {
designcraft_color::swatch::resolve(&self.swatches, swatch, tint)
}
/// Give items that reuse an earlier item's id (spreads, then parents, then items anchored in
/// stories) fresh ids, so a file holding such duplicates opens. The first item keeps the id and
/// what refers to it by id; a renumbered text frame takes its place in its story's thread.
pub fn renumber_duplicate_items(&mut self) {
fn walk_mut(it: &mut Item, f: &mut impl FnMut(&mut Item)) {
f(it);
for c in it.children_mut().into_iter().flatten() {
walk_mut(Arc::make_mut(c), f);
}
}
fn story_items(st: &mut Story, f: &mut impl FnMut(&mut Item)) {
for o in &mut st.objects {
walk_mut(&mut Arc::make_mut(o).item, f);
}
for t in st.tables.values_mut() {
for c in &mut Arc::make_mut(t).cells {
story_items(&mut c.text, f);
}
}
for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
story_items(&mut Arc::make_mut(n).text, f);
}
}
let mut seen = std::collections::HashSet::new();
let mut dups = std::collections::HashSet::new();
let mut next = self.next_id;
let mut note = |id: ItemId| {
next = next.max(id.0);
if !seen.insert(id) {
dups.insert(id);
}
};
for sp in self.spreads.iter().chain(self.parents.iter()) {
for it in &sp.items {
it.walk(&mut |i| note(i.id));
}
}
for (_, i) in self.anchored_items() {
note(i.id);
}
if dups.is_empty() {
return;
}
let mut seen = std::collections::HashSet::new();
// (story, id in the file) → the ids its frames carry now, in walk order.
let mut threads: std::collections::HashMap<(StoryId, ItemId), Vec<ItemId>> = std::collections::HashMap::new();
let mut fix = |it: &mut Item| {
let old = it.id;
if !dups.contains(&old) {
return;
}
if !seen.insert(old) {
// An id at the top of the range has no successor: leave the duplicate for
// `check()` to refuse rather than overflow.
let Some(n) = next.checked_add(1) else { return };
next = n;
it.id = ItemId(next);
}
if let Some(t) = it.text_frame() {
threads.entry((t.story, old)).or_default().push(it.id);
}
};
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
for it in &mut Arc::make_mut(sp).items {
walk_mut(Arc::make_mut(it), &mut fix);
}
}
for st in self.stories.values_mut() {
story_items(Arc::make_mut(st), &mut fix);
}
self.next_id = next;
for ((sid, old), ids) in threads {
let Some(st) = self.stories.get_mut(&sid) else { continue };
let st = Arc::make_mut(st);
let mut ids = ids.into_iter();
for f in st.frames.iter_mut().filter(|f| **f == old) {
match ids.next() {
Some(id) => *f = id,
None => break,
}
}
st.frames.extend(ids);
}
}
/// Validate structural invariants (ids unique, threads consistent, stories well-formed).
pub fn check(&self) -> Result<()> {
for f in &self.styles.composite_fonts {
+68
View File
@@ -25,6 +25,32 @@ mod story;
pub use cell::{AltFills, AltStrokes, Cell, CellStroke, Column, Row, RowHeightMode, RowKind, TableOptions};
pub use grid::CellRange;
/// Most rows a table holds, as InDesign allows.
pub const MAX_TABLE_ROWS: usize = 10_000;
/// Most columns a table holds, as InDesign allows.
pub const MAX_TABLE_COLS: usize = 1_000;
/// Most cells a table holds, whatever its shape.
///
/// A [`Cell`] is about 1.4 KB (it carries a [`Story`]), so this is roughly 340 MB of cells at the
/// limit. The row and column limits alone are not enough: 10,000 × 1,000 would be 13.6 GB, and an
/// IDML story holding one `<Table>` with 5,000 `<Row/>` and 5,000 `<Column/>` -- about 1 KB
/// zipped -- asked for 25M cells, some 34 GB. That allocation does not fail politely: it aborts
/// the process through `handle_alloc_error`, which no `catch_unwind` can save, so every open
/// document's unsaved work goes with it (#1583).
pub const MAX_TABLE_CELLS: usize = 250_000;
/// The rows and columns a table of `rows × cols` is actually given, inside the limits above.
///
/// Columns are clamped first and rows take what is left of the cell budget, because a table is
/// far more often long than wide: truncating a 5,000-column table to 1,000 columns and then its
/// rows loses less of it than the other way round. Callers that read a file report the
/// difference; the counts are a document's claim, not something to trust.
pub fn clamped_size(rows: usize, cols: usize) -> (usize, usize) {
let cols = cols.clamp(1, MAX_TABLE_COLS);
let rows = rows.clamp(1, MAX_TABLE_ROWS.min(MAX_TABLE_CELLS / cols).max(1));
(rows, cols)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Table {
@@ -48,6 +74,48 @@ mod tests {
Story::with_text(StoryId(1), t, ParaFormat::default())
}
/// A table's shape comes out of IDML and DOCX files unchecked, and `Cell` is about 1.4 KB, so
/// a file claiming 5,000 x 5,000 asked for 25M cells -- some 34 GB. That allocation aborts the
/// process rather than failing, taking every open document's unsaved work with it (#1583).
#[test]
fn a_table_larger_than_the_limits_is_clamped_not_allocated() {
// The reported shape: 5,000 rows and 5,000 columns.
let t = Table::new(1, 5_000, 5_000, 0, 0, 500.0);
assert_eq!(t.columns.len(), MAX_TABLE_COLS, "columns are clamped to InDesign's limit");
assert!(t.cells.len() <= MAX_TABLE_CELLS, "{} cells is within the budget", t.cells.len());
assert_eq!(t.rows.len() * t.columns.len(), t.cells.len(), "the grid stays rectangular");
// What it would have been: 25,000,000 cells at 1,360 bytes each.
assert!(25_000_000 * std::mem::size_of::<Cell>() > 30_000_000_000, "the size this test is about");
// Freed before the next large table, so the peak stays at one of them.
drop(t);
// A long table keeps its rows while it fits the budget (the budget itself is checked by
// `clamped_size`, which allocates nothing).
let long = Table::new(2, 10_000, 5, 0, 0, 500.0);
assert_eq!((long.rows.len(), long.columns.len()), (10_000, 5), "a long thin table is untouched");
assert_eq!(clamped_size(10_000, 25), (10_000, 25), "10,000 x 25 is inside the budget");
drop(long);
// Past the row limit the rows are clamped too.
let longer = Table::new(3, 50_000, 2, 0, 0, 100.0);
assert_eq!(longer.rows.len(), MAX_TABLE_ROWS, "rows are clamped to InDesign's limit");
drop(longer);
// Header, body and footer share the budget, and the table still has a body.
let with_parts = Table::new(4, 5_000, 5_000, 3, 2, 500.0);
assert!(with_parts.rows.len() <= MAX_TABLE_ROWS, "{} rows", with_parts.rows.len());
assert!(with_parts.cells.len() <= MAX_TABLE_CELLS, "{} cells", with_parts.cells.len());
assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Header), "the header survives");
assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Footer), "the footer survives");
assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Body), "and so does a body row");
drop(with_parts);
// A header and footer bigger than the whole budget still leave a body row.
let all_header = Table::new(6, 1, 1, MAX_TABLE_ROWS * 2, MAX_TABLE_ROWS * 2, 100.0);
assert!(all_header.rows.len() <= MAX_TABLE_ROWS, "{} rows", all_header.rows.len());
assert!(all_header.rows.iter().any(|r| r.kind == RowKind::Body), "a body row survives");
}
#[test]
fn move_row_keeps_header_and_footer_rows_in_place() {
let mut t = Table::new(1, 3, 2, 1, 1, 120.0);
+9 -1
View File
@@ -25,8 +25,16 @@ impl CellRange {
impl Table {
/// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`.
///
/// The shape is clamped to [`clamped_size`](super::clamped_size): the counts reach this from
/// IDML and DOCX files, where they are whatever the file says (#1583).
pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table {
let cols = cols.max(1);
// Header, body and footer rows share one budget: the total is clamped first, then the
// header and footer take what they can of it, and the body keeps at least one row.
let (total, cols) = super::clamped_size(body_rows.max(1).saturating_add(header).saturating_add(footer), cols);
let header = header.min(total.saturating_sub(1));
let footer = footer.min(total.saturating_sub(header).saturating_sub(1));
let body_rows = total.saturating_sub(header).saturating_sub(footer).max(1);
let mut rows = Vec::new();
rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() }));
rows.extend((0..body_rows.max(1)).map(|_| Row::default()));
+5 -3
View File
@@ -18,7 +18,7 @@ use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, id_param, ok, str_param};
use crate::{DocState, Result, Session};
use fill::{FillReport, Maps, MissingImage, Sources, copy_item, fill_row, identity_map};
use fill::{FillReport, ImageCache, Maps, MissingImage, Sources, copy_item, fill_row, identity_map};
use parse::{
Table, TileInput, extension, fingerprint_of, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects,
tile,
@@ -210,7 +210,8 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default();
let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf);
let doc_dir = st.path.as_deref().and_then(parent_dir);
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() };
let images = ImageCache::default();
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images };
fill_row(&mut doc, &source.fields, &row, record as u32, &maps, &sources);
st.doc = Arc::new(doc);
st.preview_record = Some(record as u32);
@@ -246,7 +247,8 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
warnings.extend(table.warnings.iter().cloned());
let mut merged = src.clone();
let mut missing = Vec::new();
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() };
let images = ImageCache::default();
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images };
let mut sink = Sink { warnings: &mut warnings, missing: &mut missing, parent: false };
if options.per_page == "multiple" {
tile_records(&mut merged, &src, &table, &selected, &sources, &mut sink)?;
+94 -13
View File
@@ -4,7 +4,7 @@
//! unfilled template. A placeholder whose frame or story is not in the map is left as it is.
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use designcraft_doc::{
@@ -134,6 +134,44 @@ pub struct Sources<'a> {
pub options: &'a MergeOptions,
pub data_dir: Option<&'a Path>,
pub doc_dir: Option<&'a Path>,
/// Images this merge has already read, by the path they resolved to.
///
/// Every record used to stat and read its image again, so a 100,000-row sheet naming the same
/// file read it 100,000 times -- up to 256 MiB each, around 25 TB of I/O for one merge
/// (#1595). Shared by `&`, so the cache is behind a `RefCell`.
pub images: &'a ImageCache,
}
/// Image files read during one merge: the bytes by path, and the total read so far.
#[derive(Default)]
pub struct ImageCache {
inner: std::cell::RefCell<ImageCacheInner>,
}
#[derive(Default)]
struct ImageCacheInner {
files: std::collections::HashMap<PathBuf, std::sync::Arc<Vec<u8>>>,
bytes: usize,
}
/// Bytes of distinct image files one merge keeps. Past this the cache stops growing and files are
/// read as before, so a merge over many large images is slow rather than out of memory.
const MAX_CACHED_IMAGE_BYTES: usize = 256 << 20;
impl ImageCache {
/// The bytes of `path`, reading it once per merge. `read` is called only on a miss.
fn get_or_read(&self, path: &Path, read: impl FnOnce() -> Option<Vec<u8>>) -> Option<std::sync::Arc<Vec<u8>>> {
if let Some(found) = self.inner.borrow().files.get(path) {
return Some(found.clone());
}
let bytes = std::sync::Arc::new(read()?);
let mut inner = self.inner.borrow_mut();
if inner.bytes.saturating_add(bytes.len()) <= MAX_CACHED_IMAGE_BYTES {
inner.bytes = inner.bytes.saturating_add(bytes.len());
inner.files.insert(path.to_path_buf(), bytes.clone());
}
Some(bytes)
}
}
/// Replacements and hyperlinks collected while walking the placeholders of one record.
@@ -360,15 +398,20 @@ fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, d
}
fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, sources: &Sources, report: &mut FillReport) {
let Sources { options, data_dir, doc_dir } = *sources;
let Sources { options, data_dir, doc_dir, images } = *sources;
let cands = image_candidates(cell, data_dir, doc_dir);
let found = cands.iter().find_map(|p| {
// Refuse huge files before reading them; a too-large image counts as missing.
let len = crate::io::metadata(p).ok()?.len();
if len == 0 || len > super::MAX_IMAGE_FILE_BYTES {
return None;
}
crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b))
// Read once per merge: many records naming one file is the normal case, and reading it
// per record is the amplification (#1595).
let bytes = images.get_or_read(p, || {
// Refuse huge files before reading them; a too-large image counts as missing.
let len = crate::io::metadata(p).ok()?.len();
if len == 0 || len > super::MAX_IMAGE_FILE_BYTES {
return None;
}
crate::io::read_file(p).ok().filter(|b| !b.is_empty())
})?;
Some((p.clone(), bytes))
});
let Some((path, bytes)) = found else {
if let Some(it) = doc.item_mut(item) {
@@ -430,7 +473,9 @@ fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, c
name,
mime: designcraft_render::image_mime(&bytes).to_string(),
link,
data: Arc::new(bytes),
// The cached buffer is shared with every other record naming this file, rather than
// copied per record (#1595).
data: bytes,
pixels: Some((pw, ph)),
..Default::default()
});
@@ -523,20 +568,56 @@ mod link_tests {
}
}
#[cfg(all(test, unix))]
#[cfg(test)]
mod image_path_tests {
use std::path::Path;
use super::image_candidates;
/// A merge image field names a file, and a field that names **another computer** must resolve
/// to nothing: a UNC path is how a hostile data file turns a merge into an outbound SMB
/// connection, and on Windows that means handing over NTLM credentials. This ran only on
/// unix, so the refusal was unverified on the one platform where UNC means anything (#1601).
#[test]
fn image_cells_resolve_beside_the_data_and_never_to_other_computers() {
fn an_image_cell_never_resolves_to_another_computer() {
let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc"));
for cell in [r"\\192.0.2.1\share\x.png", "file://192.0.2.1/s/x.png", "//192.0.2.1/s/x.png", "https://example.com/x.png"] {
assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}");
}
let found = image_candidates("img/x.png", Some(data), Some(doc));
assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]);
// A field naming nothing resolves to nothing, whatever the platform.
assert!(image_candidates("", Some(data), Some(doc)).is_empty(), "an empty cell");
assert!(image_candidates("img/x.png", None, None).is_empty(), "not beside the working directory");
}
/// The Windows shapes `craft-docpath` refuses, asserted where they have meaning: the extended
/// UNC prefix, a device namespace path, and a legacy device name (with an extension, since a
/// reserved name is reserved whatever follows it).
#[cfg(windows)]
#[test]
fn windows_device_and_network_paths_are_refused() {
let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc"));
for cell in [r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\pipe\x", r"\\?\GLOBALROOT\Device\HarddiskVolume1\x", "CON", "NUL.png"] {
assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}");
}
}
/// An extended-length path is not one of them: `\\?\C:\x` names a local file, the same one
/// `C:\x` names, and `craft-docpath`'s own suite classifies it `PathKind::Local`. A data file
/// could already give the plain spelling, so refusing this one would shut out long paths
/// without shutting anything out (noted in review).
#[cfg(windows)]
#[test]
fn an_extended_length_drive_path_is_a_local_file() {
let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc"));
assert!(!image_candidates(r"\\?\C:\secret\x.png", Some(data), Some(doc)).is_empty());
}
/// Resolution beside the data and the document, with absolute paths this platform means.
#[cfg(unix)]
#[test]
fn an_image_cell_resolves_beside_the_data_then_the_document() {
let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc"));
let found = image_candidates("img/x.png", Some(data), Some(doc));
assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]);
}
}
+63
View File
@@ -519,6 +519,69 @@ fn typed_fields_keep_style_and_unknown_markers() {
assert_eq!(story.format_after(at).over.size, Some(21.0));
}
/// Every record stat'd and read its image again, so a sheet naming one file in many rows read it
/// once per row -- up to 256 MiB each time (#1595). A merge reads a file once and the records
/// share the bytes.
#[test]
fn one_image_named_by_many_records_is_read_once() {
let dir = scratch("image-reads");
let data = dir.join("data");
std::fs::create_dir_all(&data).unwrap();
let shared = dir.join("shared.png");
write_png(&shared);
// 25 records, all naming the same file.
let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect();
write(&data.join("many.csv"), &format!("@Photo\n{rows}"));
let mut s = Session::new();
new_doc(&mut s, 1);
exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")}));
let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]}));
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["records"], 25);
assert_eq!(r["missingImages"].as_array().map(Vec::len).unwrap_or(0), 0, "every record found the image");
// Each record has its graphic, and they share one buffer: the document holds the file's bytes
// once however many records named it.
let doc = &s.documents()[s.active_index().unwrap()].doc;
let graphics: Vec<_> = doc
.spreads
.iter()
.flat_map(|sp| &sp.items)
.filter_map(|it| match &it.content {
Content::Graphic(g) => Some(g.asset),
_ => None,
})
.collect();
assert_eq!(graphics.len(), 25, "one graphic per record");
}
/// The same merge, counting what the engine actually hands the file system: `io::touched` records
/// every path read, so the reads are counted rather than inferred. The document already shares
/// identical asset bytes, so memory alone does not show this -- the I/O is the bug (#1595).
#[test]
fn a_merge_reads_a_repeated_image_once_not_once_per_record() {
let dir = scratch("image-read-count");
let data = dir.join("data");
std::fs::create_dir_all(&data).unwrap();
let shared = dir.join("shared.png");
write_png(&shared);
let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect();
write(&data.join("many.csv"), &format!("@Photo\n{rows}"));
let mut s = Session::new();
new_doc(&mut s, 1);
exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")}));
let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]}));
let (r, seen) = crate::io::touched(|| exec(&mut s, "data.merge", json!({})));
assert_eq!(r["records"], 25);
let reads = seen.iter().filter(|p| p.ends_with("shared.png")).count();
assert!(reads <= 2, "the image is read once for the merge, not once per record: {reads} reads for 25 records");
}
#[test]
fn images_resolve_in_order_and_roles_win() {
let dir = scratch("images");
+10
View File
@@ -1096,6 +1096,16 @@ mod text_link_tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn placed_plain_text_drops_story_markers() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let b64 = crate::cmd::base64_encode("a\u{E009}b\u{E000}c\u{E002}d".as_bytes());
let r = s.execute("file.place", &json!({"base64": b64, "name": "n.txt", "rect": [72, 72, 300, 200]})).unwrap();
let st = s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap();
assert_eq!(st.text, "abcd");
}
#[test]
fn create_links_when_placing_text_files_preference() {
let dir = std::env::temp_dir().join(format!("dc-text-link-pref-{}", std::process::id()));
+10 -1
View File
@@ -86,7 +86,15 @@ mod tests {
} else {
assert_eq!(
command,
["xps-print", "printer=Office", "copies=2", "collate=true", "duplex=(queue default)", "scaling=shrink-to-fit", "color=color"]
[
"xps-print",
"printer=Office",
"copies=2",
"collate=true",
"duplex=(queue default)",
"scaling=shrink-to-fit",
"color=(driver default)"
]
);
}
assert!(r["bytes"].as_u64().unwrap() > 500);
@@ -108,6 +116,7 @@ mod tests {
assert_eq!(job.printer.as_deref(), Some("Office (east)"));
assert_eq!(job.copies, 2);
assert_eq!(job.duplex, None);
assert!(!job.grayscale);
assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit);
assert!(!job.title.is_empty());
Ok("request id is Office-1".into())
+46
View File
@@ -320,3 +320,49 @@ mod resize_border_tests {
assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err());
}
}
#[cfg(test)]
mod insert_size_tests {
use serde_json::json;
use crate::Session;
/// A table size that is given but is not one a table can have used to be corrected in
/// silence: `{"rows": 0}` made a one-row table, `-1` and `1.5` made the default four, and
/// 10000 was capped at 500 — so a caller's mistake became a table nobody asked for.
#[test]
fn a_table_size_insert_cannot_mean_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": ""})).unwrap();
let at = |s: &mut Session| {
s.execute("text.select", &json!({"story": frame["story"].clone(), "anchor": 0, "focus": 0})).unwrap();
};
for (params, names) in [
(json!({"rows": 0}), "rows 0: 1 to 500"),
(json!({"rows": -1}), "rows -1: 1 to 500"),
(json!({"rows": 1.5}), "rows 1.5: 1 to 500"),
(json!({"rows": 10000}), "rows 10000: 1 to 500"),
(json!({"cols": 0}), "cols 0: 1 to 200"),
(json!({"columns": 201}), "cols 201: 1 to 200"),
(json!({"headerRows": 101}), "headerRows 101: 0 to 100"),
(json!({"footerRows": -2}), "footerRows -2: 0 to 100"),
] {
at(&mut s);
let e = s.execute("table.insert", &params).expect_err("refused").to_string();
assert!(e.contains(names), "{params}: {e}");
}
// Nothing was inserted by any of those.
at(&mut s);
assert!(s.execute("table.get", &json!({})).is_err(), "no table yet");
// The sizes a table can have still work, and absent means the default four by four.
at(&mut s);
s.execute("table.insert", &json!({})).unwrap();
let t = s.execute("table.get", &json!({})).unwrap();
assert_eq!(t["cells"].as_array().unwrap().len(), 16, "four by four");
at(&mut s);
s.execute("table.insert", &json!({"rows": 1, "cols": 2, "headerRows": 1})).unwrap();
let t = s.execute("table.get", &json!({})).unwrap();
assert_eq!(t["cells"].as_array().unwrap().len(), 4, "one body row and one header row, two columns");
}
}
+18 -4
View File
@@ -6,10 +6,24 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
if t.cell.is_some() {
return Err(bad("table.insert", "tables can't be nested in cells"));
}
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize;
let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize;
let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
// A size that is given but is not one a table can have used to be corrected in silence:
// `{"rows": 0}` made a table with one row, `-1` and `1.5` made the default four, and 10000
// was capped at 500. Named `given` rather than `count`, which this module already imports
// for the repeat count of a row or column insert.
let given = |key: &str, alt: Option<&str>, default: u64, range: std::ops::RangeInclusive<u64>| -> Result<usize> {
let v = p.get(key).or_else(|| alt.and_then(|a| p.get(a))).filter(|v| !v.is_null());
match v {
None => Ok(default as usize),
Some(v) => match v.as_u64().filter(|n| range.contains(n)) {
Some(n) => Ok(n as usize),
None => Err(bad("table.insert", format!("{key} {v}: {} to {}", range.start(), range.end()))),
},
}
};
let rows = given("rows", None, 4, 1..=500)?;
let cols = given("cols", Some("columns"), 4, 1..=200)?;
let header = given("headerRows", None, 0, 0..=100)?;
let footer = given("footerRows", None, 0, 0..=100)?;
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
+67
View File
@@ -354,3 +354,70 @@ fn running_header_on_parent_page_follows_headings() {
s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap();
assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3"));
}
/// A running header reads a page in text order: in a two-column frame the right column's
/// headings come after the left column's, even when they sit higher on the page. (The first
/// and last were picked by height, so a dictionary page's header named its left column's last
/// entry, which sits lowest, instead of the right column's.)
#[test]
fn running_header_reads_columns_in_text_order() {
use designcraft_doc::story::COLUMN_BREAK;
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
// Left column: a line, Alpha, a line, Beta (low). Right column: Gamma (at the top), a line.
let text = format!("x\nAlpha\nx\nBeta{COLUMN_BREAK}\nGamma\ny");
let r = s.execute("frame.create", &json!({"rect": [72, 72, 472, 400], "content": "text", "text": text})).unwrap();
s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2, "gutter": 20})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
for word in ["Alpha", "Beta", "Gamma"] {
let at = text.find(word).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at + word.len()})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap();
}
s.execute("variables.define", &json!({"name": "Entry", "type": "runningHeader", "style": "Heading"})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
let y = |word: &str| crate::compose::caret(&cs, text.find(word).unwrap()).unwrap().2;
assert!(y("Gamma") < y("Beta") && y("Gamma") < y("Alpha"), "Gamma heads the right column, above the left column's headings");
let idx = s.cache.running_index(&d);
assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::FirstOnPage, 0), "Alpha");
assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::LastOnPage, 0), "Gamma");
}
/// Stories on one page are ordered by where each one's text starts there: a story whose first
/// heading sits low in its first column comes after another story's heading above it, even
/// though its later column starts higher still.
#[test]
fn running_header_orders_stories_by_their_first_heading() {
use designcraft_doc::story::COLUMN_BREAK;
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
// Story A, two columns: filler, then Alpha low in column 1; Gamma at the top of column 2.
let a_text = format!("{}Alpha{COLUMN_BREAK}\nGamma\ny", "x\n".repeat(8));
let a = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": a_text})).unwrap();
s.execute("object.textFrameOptions", &json!({"ids": [a["id"]], "columns": 2, "gutter": 12})).unwrap();
// Story B beside it: Beta between Gamma's height and Alpha's.
let b_text = "Beta".to_string();
let b = s.execute("frame.create", &json!({"rect": [320, 120, 560, 160], "content": "text", "text": b_text})).unwrap();
let story =
|s: &Session, id: &Value| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().text_frame().unwrap().story;
let (sa, sb) = (story(&s, &a["id"]), story(&s, &b["id"]));
for (sid, text, word) in [(sa, &a_text, "Alpha"), (sa, &a_text, "Gamma"), (sb, &b_text, "Beta")] {
let at = text.find(word).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at + word.len()})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap();
}
s.execute("variables.define", &json!({"name": "Entry", "type": "runningHeader", "style": "Heading"})).unwrap();
let d = s.doc().unwrap().doc.clone();
let y = |sid, text: &str, word: &str| {
let cs = s.cache.get(&d, sid, None);
let (fi, _, b, ..) = crate::compose::caret(&cs, text.find(word).unwrap()).unwrap();
d.item(cs.frames[fi].frame).unwrap().bounds().y0 + b
};
let (ya, yg, yb) = (y(sa, &a_text, "Alpha"), y(sa, &a_text, "Gamma"), y(sb, &b_text, "Beta"));
assert!(yg < yb && yb < ya, "Gamma {yg} above Beta {yb} above Alpha {ya}");
let idx = s.cache.running_index(&d);
assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::FirstOnPage, 0), "Beta");
assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::LastOnPage, 0), "Gamma");
}
+26 -1
View File
@@ -37,8 +37,21 @@ fn tagged(d: &Document) -> Vec<ItemId> {
keyed.into_iter().map(|(_, i)| i).collect()
}
/// Escapes markup characters and drops characters XML 1.0 cannot hold.
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\t' | '\n' | '\r' => out.push(c),
c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
c => out.push(c),
}
}
out
}
/// Plain text of a story (markers dropped, forced breaks as newlines).
@@ -475,6 +488,18 @@ mod tests {
assert!(s.execute("xml.validate", &json!({})).is_err());
}
#[test]
fn export_xml_drops_forbidden_control_chars() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s
.execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "A\u{1}B\u{8}C\u{FFFE}\u{FFFF}", "caret": false}))
.unwrap();
s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap();
let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
assert!(x.contains("<story>ABC</story>"), "{x:?}");
}
#[test]
fn tag_export_and_import_xml() {
let mut s = Session::new();
+2
View File
@@ -251,6 +251,7 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
upgrade(&mut doc, u32::try_from(version).unwrap_or(u32::MAX));
doc.merge_duplicate_assets();
doc.mark_differing_margins_local();
doc.renumber_duplicate_items();
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
}
@@ -318,6 +319,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
upgrade(&mut doc, 1);
doc.merge_duplicate_assets();
doc.mark_differing_margins_local();
doc.renumber_duplicate_items();
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
}
+102
View File
@@ -734,3 +734,105 @@ fn too_many_entries_are_refused() {
let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an archive with 100 001 entries loaded") };
assert!(err.to_string().contains("entries"), "{err}");
}
/// Every item id in `d` (spreads, parents, anchored), in walk order.
fn all_item_ids(d: &Document) -> Vec<u64> {
let mut ids = Vec::new();
for sp in d.spreads.iter().chain(d.parents.iter()) {
for it in &sp.items {
it.walk(&mut |i| ids.push(i.id.0));
}
}
ids.extend(d.anchored_items().iter().map(|(_, i)| i.id.0));
ids
}
fn set_item_id(d: &mut Document, index: usize, id: designcraft_doc::ItemId) {
let sp = Arc::make_mut(&mut d.spreads[0]);
Arc::make_mut(&mut sp.items[index]).id = id;
}
#[test]
fn files_with_duplicate_item_ids_open_with_unique_ids() {
let mut d = Document::new(&NewDocument::default());
let one = png_asset(&mut d, &[1]);
let two = png_asset(&mut d, &[2]);
let a = place(&mut d, one, 0.0);
place(&mut d, two, 0.0);
set_item_id(&mut d, 1, a);
let back = load(&save(&d).unwrap()).unwrap();
back.check().unwrap();
let ids = all_item_ids(&back);
assert_eq!(ids.len(), 2);
assert_ne!(ids[0], ids[1]);
assert!(back.next_id >= ids[0].max(ids[1]));
let assets: Vec<_> = back.spreads[0].items.iter().map(|i| i.graphic().unwrap().asset).collect();
assert_eq!(assets, vec![one, two]);
assert_eq!(back.item(a).and_then(|i| i.graphic()).map(|g| g.asset), Some(one));
}
#[test]
fn duplicate_text_frame_ids_keep_each_story_in_its_own_frame() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "first", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 210.0, 200.0, 400.0), lid, "second", ParaFormat::default()).unwrap();
let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 210.0, 400.0, 400.0), lid, "", ParaFormat::default()).unwrap();
d.thread(b, c).unwrap();
// The second story's first frame carries the first frame's id.
set_item_id(&mut d, 1, a);
let st = d.story_mut(sb).unwrap();
assert_eq!(st.frames, vec![b, c]);
st.frames[0] = a;
let back = load(&save(&d).unwrap()).unwrap();
back.check().unwrap();
let ids = all_item_ids(&back);
let unique: HashSet<_> = ids.iter().collect();
assert_eq!(unique.len(), 3);
assert_eq!(back.stories[&sa].frames, vec![a]);
assert_eq!(back.stories[&sa].text, "first");
let frames = &back.stories[&sb].frames;
assert_eq!(frames.len(), 2);
assert_ne!(frames[0], a);
assert_eq!(frames[1], c);
assert_eq!(back.item(frames[0]).and_then(|i| i.text_frame()).map(|t| t.story), Some(sb));
assert_eq!(back.stories[&sb].text, "second");
}
#[test]
fn duplicate_anchored_item_ids_are_renumbered() {
use designcraft_doc::anchored::{AnchorPosition, AnchoredObject};
use designcraft_doc::geom::shapes;
use designcraft_doc::{Item, Shape};
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "ab", ParaFormat::default()).unwrap();
let asset = png_asset(&mut d, &[1]);
let g = place(&mut d, asset, 0.0);
let item = Item::new(g, lid, Shape::Oval, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::default()));
let back = load(&save(&d).unwrap()).unwrap();
back.check().unwrap();
let ids = all_item_ids(&back);
assert_eq!(ids.len(), 3);
assert_eq!(ids.iter().collect::<HashSet<_>>().len(), 3);
assert_eq!(back.item(g).and_then(|i| i.graphic()).map(|g| g.asset), Some(asset));
let anchored = back.anchored_items();
assert_eq!(anchored.len(), 1);
assert_eq!(anchored[0].1.shape, Shape::Oval);
assert_ne!(anchored[0].1.id, g);
}
#[test]
fn duplicate_ids_at_the_top_of_the_range_do_not_overflow() {
let mut d = Document::new(&NewDocument::default());
let one = png_asset(&mut d, &[1]);
let two = png_asset(&mut d, &[2]);
place(&mut d, one, 0.0);
place(&mut d, two, 0.0);
set_item_id(&mut d, 0, designcraft_doc::ItemId(u64::MAX));
set_item_id(&mut d, 1, designcraft_doc::ItemId(u64::MAX));
d.renumber_duplicate_items();
assert_eq!(all_item_ids(&d), vec![u64::MAX, u64::MAX]);
assert!(d.check().is_err());
}
+29 -1
View File
@@ -206,10 +206,10 @@ fn xref_format_el(i: usize, f: &designcraft_doc::XrefFormat) -> El {
};
let mut rest = f.definition.as_str();
while let Some(a) = rest.find('<') {
let Some(b) = rest[a..].find('>') else { break };
if a > 0 {
block(&mut el, "CustomStringBuildingBlock", &rest[..a], "", false);
}
let Some(b) = rest[a..].find('>') else { break };
let tag = rest[a + 1..a + b].trim().trim_end_matches('/').trim();
let attr = |k: &str| tag.find(&format!("{k}=\"")).and_then(|i| tag[i + k.len() + 2..].split('"').next()).unwrap_or("").to_string();
match tag.split_whitespace().next().unwrap_or("") {
@@ -350,3 +350,31 @@ fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El {
}
e
}
#[cfg(test)]
mod tests {
use super::*;
use crate::xml::Node;
fn custom_text(definition: &str) -> String {
let f = designcraft_doc::XrefFormat { name: "x".into(), definition: definition.into() };
xref_format_el(0, &f)
.children
.iter()
.filter_map(|n| match n {
Node::El(e) if e.get("BlockType") == Some("CustomStringBuildingBlock") => e.get("CustomText").map(str::to_string),
_ => None,
})
.collect()
}
#[test]
fn xref_format_unclosed_angle_bracket_keeps_text_once() {
assert_eq!(custom_text("a < b"), "a < b");
}
#[test]
fn xref_format_well_formed_keeps_custom_text() {
assert_eq!(custom_text("p <pageNum /> of <chapNum />"), "p of ");
}
}
+10
View File
@@ -169,6 +169,16 @@ impl<'a> Ex<'a> {
.attr("SubscriptSize", num(a.subscript_size))
.attr("SubscriptPosition", num(a.subscript_position)),
);
// InDesign always writes the document's default story preference; readers such as Okapi's IDML
// filter take the spread reading order from it and fail without it.
root.push(
El::new("StoryPreference")
.attr("OpticalMarginAlignment", "false")
.attr("OpticalMarginSize", "12")
.attr("FrameType", "TextFrameType")
.attr("StoryOrientation", "Horizontal")
.attr("StoryDirection", "LeftToRightDirection"),
);
document(&root)
}
}
+11
View File
@@ -139,6 +139,14 @@ struct Importer<'r> {
/// Graphic `Self` → stand-in image for a linked file that can't be read.
previews: &'r HashMap<String, Arc<Vec<u8>>>,
next_id: u64,
/// Cells every table in this import has reserved. One table is capped, but a file chooses
/// how many tables it has, so the cap has to be document-wide as well (noted in review).
table_cells: usize,
/// Tables whose size the file claimed and did not get, warned about so far. A file chooses
/// how many tables it has, so a line each is its own flood (noted in review).
table_warnings: usize,
/// Whether the file has already been told its tables ran out of cells between them.
table_budget_spent: bool,
settings: DocSettings,
default_margins: Option<(Margins, Columns)>,
swatches: Vec<Swatch>,
@@ -225,6 +233,9 @@ impl<'r> Importer<'r> {
read_link,
previews,
next_id: 0,
table_cells: 0,
table_warnings: 0,
table_budget_spent: false,
settings: DocSettings::default(),
default_margins: None,
swatches: specials,
+5 -2
View File
@@ -171,8 +171,11 @@ impl<'r> Importer<'r> {
b.fresh = true;
}
"Table" => {
let t = self.table(c);
b.push_table(t, pf, cf);
// `None` once the file-wide cell budget is spent: the table is not read
// at all rather than kept as a 1 x 1 (noted in review).
if let Some(t) = self.table(c) {
b.push_table(t, pf, cf);
}
}
"HyperlinkTextDestination" | "ParagraphDestination" => {
b.anchors.push((c.get("Self").unwrap_or("").to_string(), c.get("Name").unwrap_or("Anchor").to_string()));
+131 -5
View File
@@ -1,6 +1,39 @@
use super::values::{span, tint};
use super::{CellRange, El, Importer, RowHeightMode, StoryId, Table, names, swatch};
/// Cells every table in one import may hold between them. A `Cell` carries a whole story, so
/// it is 1,360 bytes: 400,000 of them is about 520 MB, which is the 512 MiB the package reader
/// already allows one part of the file to decompress to, and the same count the spreadsheet
/// importer collects at most.
const MAX_IMPORT_TABLE_CELLS: usize = 400_000;
/// Clamp warnings one import writes before it says the rest were the same.
const MAX_TABLE_WARNINGS: usize = 20;
/// How large a table of `rows` x `cols` may be read when `reserved` cells have already been
/// taken, and whether the file-wide budget is what cut it down -- or `None` when the budget is
/// spent and the table is not read at all.
///
/// The per-table cap leaves a file free to hold many capped tables, so each is also clamped
/// against what is left. While there is budget, one row and one column are always kept: the table
/// is then still in the document, still says where its text went, and a file pays one cell for a
/// table it really does contain.
///
/// Past that there is nothing left to pay with. Keeping a 1 x 1 table for every further `<Table>`
/// was unbounded in the one direction the budget was meant to close: `<Table></Table>` is fifteen
/// bytes, so the 512 MiB a part may decompress to holds some 36 million of them, and a `Cell`
/// each is tens of gigabytes with every table inside both caps (noted in review).
fn budgeted_size(rows: usize, cols: usize, reserved: usize) -> Option<(usize, usize, bool)> {
let (mut nr, mut nc) = designcraft_doc::table::clamped_size(rows, cols);
let left = MAX_IMPORT_TABLE_CELLS.checked_sub(reserved).filter(|left| *left > 0)?;
let over_budget = nr.saturating_mul(nc) > left;
if over_budget {
nc = nc.min(left);
nr = nr.min((left / nc).max(1));
}
Some((nr, nc, over_budget))
}
/// Preserve the legacy uniform field only when all four sides explicitly specify every
/// property. Partial records must stay sparse so they can inherit from a parent style.
pub(super) fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option<designcraft_doc::CellStroke> {
@@ -21,12 +54,44 @@ pub(super) fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4])
impl<'r> Importer<'r> {
/// `<Table>`: rows, columns and cells (cell `Name` is `column:row`).
pub(super) fn table(&mut self, e: &El) -> Table {
let id = self.alloc();
pub(super) fn table(&mut self, e: &El) -> Option<Table> {
let rows: Vec<&El> = e.find_all("Row").collect();
let cols: Vec<&El> = e.find_all("Column").collect();
let nr = rows.len().max(1);
let nc = cols.len().max(1);
// A file's row and column counts are its claim, and a table of them is allocated whole: a
// story holding one `<Table>` with 5,000 of each asked for 25M cells, around 34 GB, which
// aborts the process instead of failing (#1583). What is read is clamped, and the rest of
// this function walks the clamped slices -- indexing `t.rows`/`t.columns` by the file's
// own counts would be out of bounds.
//
// Reserved before `Table::new`, so a cell's own story cannot spend the budget this table
// is counting on.
let Some((nr, nc, over_budget)) = budgeted_size(rows.len().max(1), cols.len().max(1), self.table_cells) else {
// Said once: the file that reaches this is the one with millions of tables in it.
if !self.table_budget_spent {
self.table_budget_spent = true;
self.warnings.push(format!(
"the tables in this file claim more than {MAX_IMPORT_TABLE_CELLS} cells between them: the rest of them were not read"
));
}
return None;
};
self.table_cells = self.table_cells.saturating_add(nr.saturating_mul(nc));
if nr < rows.len() || nc < cols.len() {
let why = if over_budget { "more than the tables in one file can hold between them" } else { "more than a table can hold" };
self.table_warnings += 1;
match self.table_warnings.cmp(&MAX_TABLE_WARNINGS) {
std::cmp::Ordering::Less => {
self.warnings.push(format!("a table of {} rows and {} columns was read as {nr} x {nc}: {why}", rows.len(), cols.len()))
}
std::cmp::Ordering::Equal => {
self.warnings.push(format!("more than {MAX_TABLE_WARNINGS} tables were read smaller than this file claims"))
}
std::cmp::Ordering::Greater => {}
}
}
let id = self.alloc();
let rows = &rows[..nr.min(rows.len())];
let cols = &cols[..nc.min(cols.len())];
let mut t = Table::new(id, nr, nc, 0, 0, 0.0);
t.options.direction = if e.get("TableDirection") == Some("RightToLeftDirection") {
designcraft_doc::TextDirection::RightToLeft
@@ -113,7 +178,7 @@ impl<'r> Importer<'r> {
for rg in regions {
let _ = t.merge(rg);
}
t
Some(t)
}
/// A cell's local fill, insets, alignment, rotation and edge strokes.
@@ -157,3 +222,64 @@ impl<'r> Importer<'r> {
}
}
}
#[cfg(test)]
mod budget_tests {
use super::{MAX_IMPORT_TABLE_CELLS, budgeted_size};
/// One table is capped, but a file chooses how many tables it has: three tables each at the
/// per-table cap asked for 750,000 cells, about 1 GB of `Cell`, with every table inside its
/// own limit (noted in review of #1583).
#[test]
fn the_tables_in_one_file_are_capped_between_them() {
let mut reserved = 0;
let mut sizes = Vec::new();
for _ in 0..4 {
let size = budgeted_size(500, 1000, reserved);
if let Some((nr, nc, _)) = size {
reserved += nr * nc;
}
sizes.push(size);
}
assert_eq!(sizes[0], Some((250, 1000, false)), "the first table is held by the per-table cap alone");
assert_eq!(sizes[1], Some((150, 1000, true)), "the second gets what is left of the file's budget");
assert_eq!(sizes[2], None, "and the third is not read: there is nothing left to pay for it");
assert_eq!(sizes[3], None, "nor any after it");
assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS, "the budget is spent exactly, not overspent");
}
/// The budget is a hard limit, not a floor: keeping one cell for every further table was
/// unbounded in the one direction the budget exists to close, since a `<Table></Table>` is
/// fifteen bytes and a part may decompress to 512 MiB (noted in review).
#[test]
fn a_spent_budget_stops_reading_tables_altogether() {
let claims = [(0, 0), (1, 1), (7, 3), (usize::MAX, usize::MAX), (1, usize::MAX), (usize::MAX, 1), (10_000, 1_000), (2, 0)];
let mut reserved = 0usize;
let mut read = 0usize;
let mut skipped = 0usize;
// Every claim, several times over, in an order that exhausts the budget part way through.
for _ in 0..4 {
for (rows, cols) in claims {
match budgeted_size(rows.max(1), cols.max(1), reserved) {
Some((nr, nc, _)) => {
assert!(nr >= 1 && nc >= 1, "{rows} x {cols} was read as {nr} x {nc}: a table has nowhere to put its text");
reserved += nr * nc;
read += 1;
}
None => skipped += 1,
}
assert!(reserved <= MAX_IMPORT_TABLE_CELLS, "{read} tables reserved {reserved} cells, past the budget");
}
}
assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS, "this sequence does reach the budget");
assert!(skipped > 0, "and does go on asking after it: {skipped} tables were not read");
}
/// Below the budget nothing is clamped by it, so an ordinary file is read exactly as before.
#[test]
fn an_ordinary_file_is_not_touched_by_the_budget() {
for (rows, cols) in [(1, 1), (2, 3), (50, 20), (250, 1000)] {
assert_eq!(budgeted_size(rows, cols, 0), Some((rows, cols, false)), "{rows} x {cols}");
}
}
}
+8
View File
@@ -554,6 +554,14 @@ fn basic_paragraph_is_normal_paragraph_style() {
assert_eq!(back.styles.paragraph.iter().filter(|p| p.name == st::BASIC_PARAGRAPH).count(), 1);
}
/// Like InDesign, the export carries the document's default story preference, which IDML readers expect.
#[test]
fn export_writes_the_document_story_preference() {
let d = import_idml_with(&fixture(), &|_| None).unwrap();
let prefs = zip_text(&export_idml(&d), "Resources/Preferences.xml");
assert!(prefs.contains(r#"<StoryPreference OpticalMarginAlignment="false" OpticalMarginSize="12" FrameType="TextFrameType" StoryOrientation="Horizontal" StoryDirection="LeftToRightDirection"/>"#), "{prefs}");
}
/// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults
/// for a new document.
#[test]
+60
View File
@@ -11,6 +11,27 @@ pub(super) fn now_unix() -> Option<i64> {
None
}
/// The date an exported file records, when the caller gives none: `SOURCE_DATE_EPOCH` if it holds
/// a non-negative whole number of seconds, else the clock. Pinning it makes an export repeat its
/// bytes, which is otherwise impossible -- the only thing that differs between two exports of one
/// document is the timestamp, and the instance id krilla derives from the bytes follows it.
///
/// <https://reproducible-builds.org/specs/source-date-epoch/>
pub(super) fn export_time() -> Option<i64> {
pinned_or(|name| std::env::var(name).ok(), now_unix)
}
/// [`export_time`] with the environment and the clock given, so a test can show which wins.
fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
let pinned = get("SOURCE_DATE_EPOCH").and_then(|v| v.trim().parse::<i64>().ok()).filter(|secs| PDF_DATES.contains(secs));
pinned.or_else(clock)
}
/// The dates a PDF can hold, as Unix seconds: `date_time` clamps the year to 0..=9999, so a value
/// outside this would be recorded as some other date rather than the one asked for. A value that
/// cannot be honoured is left to the clock, as a malformed one is.
const PDF_DATES: std::ops::RangeInclusive<i64> = 0..=253_402_300_799;
/// The document information from File Info (title, author, description, keywords). `title` and
/// `author` from the export options replace File Info's.
pub(super) fn file_info_metadata(doc: &Document, title: Option<&str>, author: Option<&str>) -> Metadata {
@@ -49,3 +70,42 @@ pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
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)
}
#[cfg(test)]
mod export_time_tests {
use super::pinned_or;
/// What an export records when the caller gives no date: `SOURCE_DATE_EPOCH` when it holds a
/// non-negative whole number of seconds, the clock whenever it does not. The environment and
/// the clock are given here rather than set, so no test changes this process's environment --
/// that is `unsafe` under edition 2024 and would reach every test beside it.
#[test]
fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() {
let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned);
let clock = || Some(1_000_000);
assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins");
assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "the epoch itself counts");
assert_eq!(pinned_or(env(Some(" 1791208980 ")), clock), Some(1_791_208_980), "spaces around it are allowed");
// A date a PDF cannot hold is left to the clock too: `date_time` would clamp the year and
// record some other date. 253402300799 is 9999-12-31T23:59:59Z, the last one it can.
assert_eq!(pinned_or(env(Some("253402300799")), clock), Some(253_402_300_799), "the last date a PDF can hold");
for ignored in [
None,
Some(""),
Some(" "),
Some("-1"),
Some("later"),
Some("1791208980.5"),
Some("99999999999999999999"),
Some("253402300800"),
Some("9223372036854775807"),
] {
assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock");
}
// A platform with no clock and no variable records no date at all.
assert_eq!(pinned_or(env(None), || None), None);
assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock");
// Only that variable is read.
assert_eq!(pinned_or(|name: &str| (name == "TZ").then(|| "1791208980".to_owned()), clock), Some(1_000_000));
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
use super::common::{tf, to_path};
use super::images::ImageContentCache;
use super::metadata::{date_time, file_info_metadata, now_unix};
use super::metadata::{date_time, export_time, file_info_metadata};
use super::*;
/// Export `doc` as PDF bytes.
@@ -162,7 +162,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
};
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..base };
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let created = opts.created.or_else(now_unix);
let created = opts.created.or_else(export_time);
let metadata = Metadata { created: created.map(date_time), ..file_info_metadata(doc, opts.title.as_deref(), opts.author.as_deref()) };
let mut writer = Writer::new(settings, metadata);
let pdf = writer.document_mut();
+60 -7
View File
@@ -10,8 +10,9 @@ use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions};
use crate::export::Sheet;
use crate::transitions::{bracketed, int_after, object, rfind};
/// A field placed on an output page: (page index, PDF rectangle, field).
pub(crate) type Placed = (usize, [f64; 4], FormField);
/// A field placed on an output page: (page index, PDF rectangle, field, border width, 0 when the
/// item has no stroke).
pub(crate) type Placed = (usize, [f64; 4], FormField, f64);
/// A placed video or sound on an output page: (page index, PDF rectangle, file name, MIME type,
/// bytes, options).
@@ -37,7 +38,8 @@ pub(crate) fn collect(doc: &Document, sheets: &[Sheet]) -> Vec<Placed> {
let mut out = Vec::new();
visit(doc, sheets, |pi, r, it| {
if let Some(f) = &it.form_field {
out.push((pi, r, f.clone()));
let bw = if it.stroke.is_none() || !it.stroke.weight.is_finite() { 0.0 } else { it.stroke.weight };
out.push((pi, r, f.clone(), bw));
}
});
out
@@ -56,6 +58,25 @@ pub(crate) fn collect_media(doc: &Document, sheets: &[Sheet]) -> Vec<PlacedMedia
out
}
/// A PDF name's characters, with everything outside the regular set written `#xx`.
///
/// ISO 32000-1 7.3.5: a name may hold only the regular characters, and `#` introduces a two-digit
/// hex escape. A media type comes from a placed file's own metadata, and only `/` was escaped, so
/// a type holding a space, a delimiter or `#` -- `video/mp4;x=(a)` -- wrote a name that ends
/// early and leaves the rest as stray tokens in the PDF's structure (#1582).
fn pdf_name(s: &str) -> String {
let regular = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.' | b'_' | b'~' | b'*' | b'@' | b'$' | b'&' | b'!' | b'^' | b'|');
let mut o = String::new();
for b in s.bytes() {
if regular(b) {
o.push(b as char);
} else {
o.push_str(&format!("#{b:02X}"));
}
}
o
}
/// A PDF text string: literal for ASCII, UTF-16BE otherwise.
fn pdf_string(s: &str) -> String {
if s.is_ascii() {
@@ -77,6 +98,16 @@ fn pdf_string(s: &str) -> String {
o
}
/// A checkbox appearance's border: the item's stroke weight, inset by half of it so it stays
/// inside the box; nothing for an unstroked item.
fn checkbox_border(bw: f64, w: f64, h: f64) -> String {
if bw > 0.0 {
format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0)
} else {
String::new()
}
}
/// Add `fields` and `media` to `pdf` (page indices in output order). `None` when the file's
/// structure isn't the simple kind this understands.
pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -> Option<Vec<u8>> {
@@ -117,7 +148,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -
let Some(&page_ref) = kids.get(*page) else { continue };
let (file, spec, rendition, screen) = (next, next + 1, next + 2, next + 3);
next += 4;
let mut body = format!("<</Type/EmbeddedFile/Subtype/{}/Length {}>>\nstream\n", mime.replace('/', "#2F"), data.len()).into_bytes();
let mut body = format!("<</Type/EmbeddedFile/Subtype/{}/Length {}>>\nstream\n", pdf_name(mime), data.len()).into_bytes();
body.extend_from_slice(data);
body.extend_from_slice(b"\nendstream");
put(&mut out, file, &body);
@@ -158,13 +189,14 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -
put(&mut out, font, b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>");
}
let mut all = Vec::new();
for (page, r, f) in fields {
for (page, r, f, bw) in fields {
let Some(&page_ref) = kids.get(*page) else { continue };
let rect = format!("[{:.2} {:.2} {:.2} {:.2}]", r[0], r[1], r[2], r[3]);
let (w, h) = (r[2] - r[0], r[3] - r[1]);
let fs = if f.font_size > 0.0 { f.font_size } else { 0.0 };
let (bc, bs) = if *bw > 0.0 { ("/BC[0.45 0.45 0.45]", format!("/BS<</W {bw:.2}>>")) } else { ("", "/BS<</W 0>>".to_string()) };
let common = format!(
"/Type/Annot/Subtype/Widget/T{}/Rect{rect}/F 4/P {page_ref} 0 R/MK<</BC[0.45 0.45 0.45]/BG[1 1 1]>>/DA(/Helv {fs:.1} Tf 0 g)",
"/Type/Annot/Subtype/Widget/T{}/Rect{rect}/F 4/P {page_ref} 0 R/MK<<{bc}/BG[1 1 1]>>{bs}/DA(/Helv {fs:.1} Tf 0 g)",
pdf_string(&f.name)
);
let req = if f.required { 2 } else { 0 };
@@ -175,7 +207,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -
}
FieldKind::CheckBox => {
// Appearances: an empty box, and a box with a check mark.
let border = format!("0.45 G 1 w 0.5 0.5 {:.2} {:.2} re S", (w - 1.0).max(0.0), (h - 1.0).max(0.0));
let border = checkbox_border(*bw, w, h);
let check = format!(
"{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S",
(w.min(h) * 0.12).max(0.8),
@@ -255,3 +287,24 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -
out.extend_from_slice(x.as_bytes());
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// A media type comes from a placed file's own metadata and was written into a PDF name with
/// only `/` escaped, so a type holding a delimiter ended the name early and left the rest as
/// tokens in the file's structure (#1582).
#[test]
fn a_pdf_name_escapes_every_irregular_byte() {
assert_eq!(pdf_name("video/mp4"), "video#2Fmp4");
assert_eq!(pdf_name("a b"), "a#20b");
assert_eq!(pdf_name("x<</Type/Catalog>>"), "x#3C#3C#2FType#2FCatalog#3E#3E");
assert_eq!(pdf_name("a%comment"), "a#25comment");
assert_eq!(pdf_name("a#1"), "a#231");
assert_eq!(pdf_name("a(b)"), "a#28b#29");
assert_eq!(pdf_name("[1 0 R]"), "#5B1#200#20R#5D");
assert_eq!(pdf_name("application-x_test.v2"), "application-x_test.v2");
assert_eq!(pdf_name("\u{e9}"), "#C3#A9");
}
}
+2 -1
View File
@@ -136,7 +136,8 @@ pub struct PdfOptions {
/// Title for the metadata; `None` = the document title.
pub title: Option<String>,
pub author: Option<String>,
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm).
/// Creation date as Unix seconds (UTC). `None` takes `SOURCE_DATE_EPOCH` when it names a date
/// a PDF can hold, else the clock (native) / omitted (wasm).
pub created: Option<i64>,
/// Tagged PDF: stories become paragraphs in reading order, graphics figures with their alt
/// text, parent-page items and printer's marks artifacts.
+30
View File
@@ -820,6 +820,36 @@ fn only_printed_fields_and_buttons_export() {
assert_eq!(all.matches("/Link").count(), shown.matches("/Link").count(), "only the shown button");
}
#[test]
fn form_field_border_follows_the_item_stroke() {
use designcraft_doc::{FieldKind, FormField, Shape, Stroke};
use designcraft_geom::shapes::rectangle;
let export = |stroke: Stroke| {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, rectangle(Rect::new(72.0, 72.0, 250.0, 96.0)));
it.stroke = stroke;
it.form_field = Some(FormField {
kind: FieldKind::TextField,
name: "name".into(),
value: String::new(),
options: Vec::new(),
multiline: false,
required: false,
font_size: 0.0,
});
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
uncompressed(&d, PdfOptions::default())
};
let none = export(Stroke { weight: 0.0, swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() });
assert!(none.contains("/Subtype/Widget"));
assert!(none.contains("/BS<</W 0>>"), "no border width");
assert!(!none.contains("/BC"), "no border colour");
let stroked = export(Stroke { weight: 1.0, ..Stroke::default() });
assert!(stroked.contains("/BS<</W 1"), "border width");
assert!(stroked.contains("/BC"), "border colour");
}
#[test]
fn extreme_page_selection_is_a_page_error_without_overflow() {
let doc = doc_with_text("Bounded page selection");
+41 -1
View File
@@ -2,7 +2,7 @@
//! repaint just those regions (typing in a frame repaints the frame, not the window).
//!
//! Conservative: anything whose effect can reach beyond the changed items' own bounds (styles,
//! swatches, layers, parent pages, text wrap, effects, text that other stories show through
//! swatches, layers, parent pages, placed files, text wrap, effects, text that other stories show through
//! cross-references, variables or footnote numbers) reports "everything" (`None`).
use std::collections::HashMap;
@@ -33,6 +33,9 @@ pub fn damage_with(old: &Document, new: &Document, cache: Option<&designcraft_co
|| old.spreads.len() != new.spreads.len()
|| old.parents.len() != new.parents.len()
|| old.parents.iter().zip(&new.parents).any(|(a, b)| !Arc::ptr_eq(a, b))
// A relinked or updated file changes what unchanged items show.
|| old.assets.len() != new.assets.len()
|| old.assets.iter().zip(&new.assets).any(|((ka, a), (kb, b))| ka != kb || !Arc::ptr_eq(a, b))
{
return None;
}
@@ -228,6 +231,43 @@ mod tests {
assert!(dmg.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(350.0, 100.0))));
}
#[test]
fn relinking_an_asset_damages_the_frame_showing_it() {
use designcraft_doc::{Asset, AssetId, Content, Graphic};
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let aid = AssetId(d.alloc());
d.assets.insert(aid, Arc::new(Asset { id: aid, name: "a.png".into(), mime: "image/png".into(), ..Default::default() }));
let mut it = Item::new(
ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)),
);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (100.0, 100.0),
xf: designcraft_geom::Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
Arc::make_mut(&mut d.spreads[0]).items.push(Arc::new(it));
let old = d.clone();
// Relink: the same asset id now holds other bytes; the item itself is unchanged.
let fresh = Asset { data: Arc::new(vec![1, 2, 3]), link: Some("/new/a.png".into()), ..(*d.assets[&aid]).clone() };
d.assets.insert(aid, Arc::new(fresh));
let dmg = damage(&old, &d);
assert!(
dmg.as_ref().is_none_or(|v| v.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(150.0, 150.0)))),
"relink reported damage {dmg:?}"
);
}
#[test]
fn editing_a_wrapping_anchored_object_damages_the_text_it_wraps() {
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
+83 -4
View File
@@ -236,7 +236,17 @@ struct Ctx {
para_names: HashMap<String, String>,
char_names: HashMap<String, String>,
footnotes: HashMap<String, El>,
/// Footnote ids already imported, and how much note text has been built. A reference clones
/// its note and walks it again, so one 1 MB footnote referenced 100k times meant ~100 GB
/// (#1584). Word gives each note its own id and references it once; a second reference to the
/// same id gets the mark without the text.
notes_done: std::collections::HashSet<String>,
note_bytes: usize,
warnings: Vec<String>,
/// What has already been said, so saying it again is a lookup rather than a scan of
/// everything said so far. A warning that names a footnote id is one per id, and the
/// document chooses how many ids it has (noted in review).
warned: std::collections::HashSet<String>,
next_table: u64,
numbering: Numbering,
/// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id.
@@ -249,7 +259,47 @@ struct Ctx {
default_para: String,
}
/// A note's tables may hold tables of their own. The document model stops nesting at 64, so
/// counting no deeper than that reaches every cell a note can actually have.
const MAX_NOTE_DEPTH: usize = 64;
/// The text `s` holds, including its tables' cell text. A table's cells each keep their text in
/// a story of their own rather than in `s.text`, which holds only the anchor, so counting
/// `s.text` scored a table of any size as a couple of bytes (noted in review).
fn note_bytes(s: &Story, depth: usize) -> usize {
let mut n = s.text.len();
if depth == 0 {
return n;
}
for t in s.tables.values() {
for c in &t.cells {
n = n.saturating_add(note_bytes(&c.text, depth - 1));
}
}
n
}
/// Warnings one import reports. A document that earns more than this has earned the point.
const MAX_WARNINGS: usize = 100;
impl Ctx {
/// Report `message` once. Beyond [`MAX_WARNINGS`] distinct messages nothing further is kept,
/// not even in the set, so neither the report nor the memory behind it grows with the
/// document -- a footnote warning names an id, and a file can hold as many ids as it likes.
fn warn(&mut self, message: String) {
if self.warnings.len() > MAX_WARNINGS {
return;
}
if !self.warned.insert(message.clone()) {
return;
}
if self.warnings.len() == MAX_WARNINGS {
self.warnings.push(format!("The Word file has more than {MAX_WARNINGS} kinds of problem: the rest are not listed."));
} else {
self.warnings.push(message);
}
}
fn para_style(&self, ppr: Option<&El>) -> String {
ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| self.default_para.clone())
}
@@ -412,11 +462,39 @@ impl Ctx {
"noBreakHyphen" => "\u{2011}".into(),
"softHyphen" => "\u{AD}".into(),
"footnoteReference" if allow_notes => {
const MAX_NOTE_BYTES: usize = 8 << 20;
let id = e.attr("id").unwrap_or_default().to_string();
// The note's text is built once per id, and only while the budget lasts. A
// repeat reference, or one past the budget, still gets its mark and its
// number -- the reference is part of the text -- with an empty note (#1584).
//
// The id is recorded only once its text has actually been kept, further down:
// recording it here meant a first reference arriving after the budget was
// spent counted as "done" with an empty note, and the next reference to it
// then claimed the text was kept at the first one (noted in review).
let again = !id.is_empty() && self.notes_done.contains(&id);
let spent = self.note_bytes >= MAX_NOTE_BYTES;
if again || spent {
let len = st.len();
if st.insert_note(len, "", ParaFormat::default()).is_some() {
let message = if again {
format!("Footnote {id} is referenced more than once in the Word file: the text is kept at its first reference.")
} else {
format!("The Word file's footnotes are longer than {} MB: the rest were imported empty.", MAX_NOTE_BYTES / (1 << 20))
};
self.warn(message);
}
continue;
}
if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) {
let mut ns = Story::new(StoryId(0));
let counter = std::mem::take(&mut self.model_counter);
self.blocks(&note, &mut ns, false);
self.model_counter = counter;
self.note_bytes = self.note_bytes.saturating_add(note_bytes(&ns, MAX_NOTE_DEPTH));
if !id.is_empty() {
self.notes_done.insert(id.clone());
}
// Word puts the reference mark in the note text: we generate the number.
let len = st.len();
match st.insert_note(len, "", ParaFormat::default()) {
@@ -425,15 +503,13 @@ impl Ctx {
n.text = ns;
}
}
None => self.warnings.push("A footnote in the Word file could not be imported.".into()),
None => self.warn("A footnote in the Word file could not be imported.".into()),
}
}
continue;
}
"drawing" | "pict" | "object" => {
if !self.warnings.iter().any(|w| w.starts_with("Images")) {
self.warnings.push("Images and drawings in the Word file were not imported (place them separately).".into());
}
self.warn("Images and drawings in the Word file were not imported (place them separately).".into());
continue;
}
_ => continue,
@@ -485,7 +561,10 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
para_names: HashMap::new(),
char_names: HashMap::new(),
footnotes: HashMap::new(),
notes_done: std::collections::HashSet::new(),
note_bytes: 0,
warnings: vec![],
warned: std::collections::HashSet::new(),
next_table: 0,
numbering: Numbering::default(),
style_lists: HashMap::new(),
+118
View File
@@ -89,6 +89,124 @@ fn a_measure_or_size_a_file_cannot_mean_is_bounded() {
assert_eq!(size, None, "a size the engine cannot mean is dropped, so the style's size stands");
}
/// A footnote reference clones its note and walks it again, so one large footnote referenced
/// many times multiplied: the audit's case is a 1 MB note referenced 100k times, around
/// 100 GB, which aborts rather than fails (#1584). The text is kept at the first reference;
/// later ones get the mark and an empty note, and the report says so.
#[test]
fn a_footnote_referenced_many_times_is_imported_once() {
let note_text = "x".repeat(2_000);
let footnotes = format!(r#"<w:footnotes {W}><w:footnote w:id="2"><w:p><w:r><w:t>{note_text}</w:t></w:r></w:p></w:footnote></w:footnotes>"#);
// One note, referenced 50 times.
let refs = r#"<w:r><w:footnoteReference w:id="2"/></w:r>"#.repeat(50);
let doc = format!(r#"<w:document {W}><w:body><w:p>{refs}</w:p></w:body></w:document>"#);
let i = import(&docx(&doc, "", &footnotes)).unwrap();
// Every reference is still in the text: the marks are the document's content.
assert_eq!(i.story.notes.len(), 50, "all 50 references are kept");
let with_text = i.story.notes.iter().filter(|n| !n.text.text.is_empty()).count();
assert_eq!(with_text, 1, "only the first reference carries the note's text");
let total: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum();
assert!(total <= note_text.len() + 64, "the note text is stored once, not 50 times: {total} bytes");
assert!(i.warnings.iter().any(|w| w.contains("referenced more than once")), "the report says what it did: {:?}", i.warnings);
// A document whose notes each have their own id is unaffected.
let many = (2..6).map(|id| format!(r#"<w:footnote w:id="{id}"><w:p><w:r><w:t>note {id}</w:t></w:r></w:p></w:footnote>"#)).collect::<String>();
let footnotes = format!(r#"<w:footnotes {W}>{many}</w:footnotes>"#);
let refs = (2..6).map(|id| format!(r#"<w:r><w:footnoteReference w:id="{id}"/></w:r>"#)).collect::<String>();
let doc = format!(r#"<w:document {W}><w:body><w:p>{refs}</w:p></w:body></w:document>"#);
let i = import(&docx(&doc, "", &footnotes)).unwrap();
assert_eq!(i.story.notes.len(), 4);
assert!(i.story.notes.iter().all(|n| n.text.text.contains("note")), "each distinct note keeps its text");
assert!(!i.warnings.iter().any(|w| w.contains("referenced more than once")), "{:?}", i.warnings);
}
/// A footnote's table keeps its cell text in stories of its own, so the budget counted a
/// table of any size as the couple of bytes of its anchor and the cap did not hold (noted in
/// review of #1584). Eighteen notes of ~600 KB of cell text each are 10.8 MB of table text
/// against an 8 MB budget.
#[test]
fn a_footnote_table_counts_toward_the_note_budget() {
let big = "x".repeat(600_000);
let cell = format!(r#"<w:tbl><w:tr><w:tc><w:p><w:r><w:t>{big}</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#);
let notes: String = (2..20).map(|id| format!(r#"<w:footnote w:id="{id}">{cell}</w:footnote>"#)).collect();
let footnotes = format!(r#"<w:footnotes {W}>{notes}</w:footnotes>"#);
let refs: String = (2..20).map(|id| format!(r#"<w:r><w:footnoteReference w:id="{id}"/></w:r>"#)).collect();
let doc = format!(r#"<w:document {W}><w:body><w:p>{refs}</w:p></w:body></w:document>"#);
let i = import(&docx(&doc, "", &footnotes)).unwrap();
// Every reference is still a mark in the text.
assert_eq!(i.story.notes.len(), 18, "all 18 references are kept");
// What was retained is the budget plus at most the one note that was already being built
// when it ran out -- not all 10.8 MB.
let held: usize = i.story.notes.iter().map(|n| note_bytes(&n.text, MAX_NOTE_DEPTH)).sum();
assert!(held <= (8 << 20) + big.len() + 1024, "the cell text is inside the budget: {held} bytes");
assert!(held > big.len(), "and the notes that fit were imported in full: {held} bytes");
assert!(i.warnings.iter().any(|w| w.contains("longer than 8 MB")), "the budget warning is reported: {:?}", i.warnings);
}
/// The report is one line per distinct footnote id, and the file chooses how many ids it
/// has: 400 ids referenced twice each meant 400 warnings, every one of them pushed after a
/// `Vec::contains` scan of all the earlier ones (noted in review of #1584). Both the report
/// and the set behind it stop at the cap.
#[test]
fn the_report_does_not_grow_with_the_document() {
let ids = 2..402;
// Small notes, so the byte budget is not what stops this -- the ids are.
let notes: String = ids.clone().map(|id| format!(r#"<w:footnote w:id="{id}"><w:p><w:r><w:t>note {id}</w:t></w:r></w:p></w:footnote>"#)).collect();
let footnotes = format!(r#"<w:footnotes {W}>{notes}</w:footnotes>"#);
let refs: String = ids
.clone()
.flat_map(|id| {
let one = format!(r#"<w:r><w:footnoteReference w:id="{id}"/></w:r>"#);
[one.clone(), one]
})
.collect();
let doc = format!(r#"<w:document {W}><w:body><w:p>{refs}</w:p></w:body></w:document>"#);
let i = import(&docx(&doc, "", &footnotes)).unwrap();
// The document is imported in full: every reference is a mark in the text.
assert_eq!(i.story.notes.len(), 800, "all 800 references are kept");
let duplicates = i.warnings.iter().filter(|w| w.contains("referenced more than once")).count();
assert!(duplicates >= 1, "the repeats are reported: {:?}", i.warnings);
assert!(i.warnings.len() <= MAX_WARNINGS + 1, "400 duplicated ids do not make 400 warnings: {} warnings", i.warnings.len());
// And the last line says the list was cut, rather than ending without explanation.
assert!(i.warnings.iter().any(|w| w.contains("the rest are not listed")), "{:?}", i.warnings);
}
/// When the budget runs out before a note's *first* reference, that reference gets an empty
/// note -- and must not be recorded as the one that kept the text, or a later reference to the
/// same id would be told the text is at a reference that has none (noted in review of #1584).
#[test]
fn a_note_whose_first_reference_is_past_the_budget_is_not_recorded_as_kept() {
// Notes totalling more than the 8 MB budget, so the later ids are never built.
let big = "x".repeat(600_000);
let notes: String = (2..20).map(|id| format!(r#"<w:footnote w:id="{id}"><w:p><w:r><w:t>{big}</w:t></w:r></w:p></w:footnote>"#)).collect();
let footnotes = format!(r#"<w:footnotes {W}>{notes}</w:footnotes>"#);
// Each id referenced twice, in order.
let refs: String = (2..20)
.flat_map(|id| {
let one = format!(r#"<w:r><w:footnoteReference w:id="{id}"/></w:r>"#);
[one.clone(), one]
})
.collect();
let doc = format!(r#"<w:document {W}><w:body><w:p>{refs}</w:p></w:body></w:document>"#);
let i = import(&docx(&doc, "", &footnotes)).unwrap();
// Every reference is kept, and the budget is respected.
assert_eq!(i.story.notes.len(), 36, "all 36 references are in the text");
let total: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum();
assert!(total <= (8 << 20) + big.len(), "the note text stays inside the budget: {total} bytes");
// The "referenced more than once" warning is only about ids whose text was actually kept.
// A note that was never built cannot have kept its text at a first reference.
let kept: std::collections::HashSet<&str> = i.story.notes.iter().filter(|n| !n.text.text.is_empty()).map(|n| n.text.text.as_str()).collect();
if i.warnings.iter().any(|w| w.contains("referenced more than once")) {
assert!(!kept.is_empty(), "that warning implies some text was kept: {:?}", i.warnings);
}
assert!(i.warnings.iter().any(|w| w.contains("longer than 8 MB")), "the budget warning is the one this document earns: {:?}", i.warnings);
}
#[test]
fn imports_styles_runs_footnotes_and_tables() {
let styles = format!(
+10 -1
View File
@@ -117,9 +117,12 @@ pub fn import(name: &str, bytes: &[u8]) -> Result<Imported, ImportError> {
Ok(plain(&tagged::decode(bytes)))
}
/// Plain text: line breaks become paragraphs (CRLF / CR normalised).
/// Plain text: line breaks become paragraphs (CRLF / CR normalised). The private-use characters
/// (U+E000–U+E1FF) that stories use as markers are dropped, since a stray one would be read as a
/// marker without its payload.
pub fn plain(text: &str) -> Imported {
let t = text.trim_start_matches('\u{FEFF}').replace("\r\n", "\n").replace('\r', "\n");
let t: String = t.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
let story = Story::with_text(designcraft_doc::StoryId(0), &t, Default::default());
Imported { story, para_styles: vec![], char_styles: vec![], warnings: vec![] }
}
@@ -137,6 +140,12 @@ mod tests {
assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_))));
}
#[test]
fn plain_text_drops_story_markers() {
let i = import("a.txt", "a\u{E009}b\u{E000}c\u{E002}d".as_bytes()).unwrap();
assert_eq!(i.story.text, "abcd");
}
#[test]
fn plain_text_utf16() {
let mut le = vec![0xFF, 0xFE];
+14 -3
View File
@@ -149,13 +149,15 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let next = chars.get(i + 1).copied().unwrap_or(' ');
if next == '\'' {
// \'hh: a Windows-1252 byte.
let hex: String = chars.iter().skip(i + 2).take(2).collect();
i += 4;
// Only valid hex digits are consumed; anything else is left for the main loop.
let hex: String = chars.iter().skip(i + 2).take(2).take_while(|c| c.is_ascii_hexdigit()).collect();
i += 2 + hex.len();
if pending > 0 {
pending -= 1;
continue;
}
if let Ok(b) = u8::from_str_radix(&hex, 16)
if hex.len() == 2
&& let Ok(b) = u8::from_str_radix(&hex, 16)
&& !stack.last().is_some_and(|s| s.skip)
{
units.reset();
@@ -314,6 +316,15 @@ mod tests {
assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab");
}
/// A short or invalid `\'hh` escape consumes only its valid hex digits.
#[test]
fn short_hex_escape_keeps_closing_brace() {
let i = import(br"{\rtf1 {\b A\'4}B}").unwrap();
assert_eq!(i.story.text, "AB");
assert_eq!(i.story.format_after(1).over.font_style, None);
assert_eq!(import(br"{\rtf1 a\'+fb}").unwrap().story.text, "a+fb");
}
#[test]
fn imports_rtf_text_and_formatting() {
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;}
+67 -9
View File
@@ -56,9 +56,18 @@ fn unescape_value(v: &str) -> String {
out
}
/// The first format version whose `cFont` and `cTypeface` values are escaped like style names.
/// Older files hold them raw, so a backslash in them is literal.
const ESCAPED_FONTS_VERSION: u32 = 8;
/// A `cFont` or `cTypeface` value: decoded in files that escape it, literal in older ones.
fn font_value(v: &str, escaped: bool) -> String {
if escaped { unescape_value(v) } else { v.to_string() }
}
/// `story` as Tagged Text.
pub fn tagged_text(doc: &Document, story: &Story) -> String {
let mut out = String::from("<ASCII-WIN>\r\n<Version:7>\r\n");
let mut out = String::from("<ASCII-WIN>\r\n<Version:8>\r\n");
let ranges = story.para_ranges();
for (i, r) in ranges.iter().enumerate() {
if i > 0 {
@@ -80,11 +89,11 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
open.push("CharStyle");
}
if let Some(v) = &f.over.font_family {
let _ = write!(out, "<cFont:{v}>");
let _ = write!(out, "<cFont:{}>", escape_value(v));
open.push("cFont");
}
if let Some(v) = &f.over.font_style {
let _ = write!(out, "<cTypeface:{v}>");
let _ = write!(out, "<cTypeface:{}>", escape_value(v));
open.push("cTypeface");
}
if let Some(v) = f.over.size {
@@ -142,6 +151,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
_ => runs.push((c.to_string(), fmt.clone())),
}
};
let mut escaped_fonts = false;
let mut units = crate::Utf16Units::default();
let mut i = 0;
let mut skipped = 0;
@@ -184,7 +194,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
}
"ParaStyle" => {
started = true;
let v = unescape_value(value.trim());
let v = unescape_value(value);
if !v.is_empty() && !para_styles.contains(&v) {
para_styles.push(v.clone());
}
@@ -193,25 +203,26 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
}
}
"CharStyle" => {
let v = unescape_value(value.trim());
let v = unescape_value(value);
fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() };
if !v.is_empty() && !char_styles.contains(&v) {
char_styles.push(v);
}
}
"cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()),
"cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()),
"cFont" => fmt.over.font_family = (!value.is_empty()).then(|| font_value(value, escaped_fonts)),
"cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| font_value(value, escaped_fonts)),
// A size the engine cannot mean (0, negative, 1e300) is dropped, so the text
// keeps its style's size (#1593).
"cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size),
"DefineParaStyle" | "DefineCharStyle" => {
let nm = value.split('=').next().unwrap_or("").trim().to_string();
let nm = value.split('=').next().unwrap_or("").to_string();
let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles };
if !nm.is_empty() && !list.contains(&nm) {
list.push(nm);
}
}
n if n.starts_with("ASCII-") || n.starts_with("UNICODE-") || n.starts_with("ANSI-") || n == "Version" => {}
"Version" => escaped_fonts = value.trim().parse::<u32>().is_ok_and(|v| v >= ESCAPED_FONTS_VERSION),
n if n.starts_with("ASCII-") || n.starts_with("UNICODE-") || n.starts_with("ANSI-") => {}
_ => skipped += 1,
}
}
@@ -278,6 +289,28 @@ mod tests {
assert_eq!(import("<ASCII-WIN>\r\n<ParaStyle:Body>A<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC");
}
/// A style name is read back exactly as the exporter wrote it, spaces included.
#[test]
fn style_names_keep_leading_and_trailing_spaces() {
let i = import("<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Body ><CharStyle: Em >Hello<CharStyle:>".as_bytes()).unwrap();
assert_eq!(i.story.paras[0].style, "Body ");
assert_eq!(i.story.char_format_at(2).style, " Em ");
assert_eq!(i.para_styles[0].name, "Body ");
assert_eq!(i.char_styles[0].name, " Em ");
}
/// A style defined with spaces in its name keeps them, so a paragraph that uses the name matches.
#[test]
fn defined_style_names_keep_leading_and_trailing_spaces() {
let src =
"<ASCII-WIN>\r\n<DefineParaStyle:Body =<cSize:12>><DefineCharStyle: Em =<cSize:12>><ParaStyle:Body ><CharStyle: Em >Hello<CharStyle:>";
let i = import(src.as_bytes()).unwrap();
assert_eq!(i.para_styles.len(), 1);
assert_eq!(i.para_styles[0].name, "Body ");
assert_eq!(i.char_styles.len(), 1);
assert_eq!(i.char_styles[0].name, " Em ");
}
/// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a
/// negative, and tagged text took all three and set them on the text (#1593). They are now
/// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size.
@@ -338,6 +371,31 @@ mod tests {
}
}
#[test]
fn font_values_with_tag_characters_round_trip() {
let doc = Document::new(&designcraft_doc::build::NewDocument::default());
for name in ["Weird>Font", "A<b>c", "x\\y", "end\\"] {
let mut story = Story::new(StoryId(0));
let mut f = CharFormat::default();
f.over.font_family = Some(name.into());
f.over.font_style = Some(name.into());
story.insert_with(0, "hello", f);
let out = tagged_text(&doc, &story);
let back = import(out.as_bytes()).unwrap();
assert_eq!(back.story.text, "hello", "{out}");
assert_eq!(back.story.char_format_at(1).over.font_family.as_deref(), Some(name), "{out}");
assert_eq!(back.story.char_format_at(1).over.font_style.as_deref(), Some(name), "{out}");
}
}
/// Version 7 files hold font values raw, so their backslashes stay literal.
#[test]
fn old_format_font_values_stay_literal() {
let i = import("<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Body><cFont:a\\b>x<cFont:><cTypeface:c\\d>y".as_bytes()).unwrap();
assert_eq!(i.story.char_format_at(1).over.font_family.as_deref(), Some("a\\b"));
assert_eq!(i.story.char_format_at(2).over.font_style.as_deref(), Some("c\\d"));
}
/// A backslash that isn't one of our escapes (older files, InDesign's `\:`) stays literal.
#[test]
fn unknown_backslashes_in_style_names_stay_literal() {
+69 -3
View File
@@ -212,19 +212,41 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
.transpose()?
.map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect())
.unwrap_or_default();
// A `t="s"` cell holds an index into the shared strings and each one is materialised as its
// own `String`, so one large shared string referenced by many cells multiplies: a 1 MB string
// and 3M references is terabytes, allocated before the grid cap below ever applies, and an
// allocation that large aborts the process rather than failing (#1584). Collection carries a
// byte budget and a cell count of its own.
const MAX_TEXT_BYTES: usize = 64 << 20;
const MAX_COLLECTED_CELLS: usize = 400_000;
let mut cells: Vec<((usize, usize), String)> = Vec::new();
let mut text_bytes = 0usize;
let mut cut = false;
if let Some(data) = sheet.child("sheetData") {
for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() {
'rows: for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() {
for (ci, c) in row.els().filter(|e| e.name == "c").enumerate() {
let at = c.attr("r").and_then(cell_ref).unwrap_or((ri, ci));
let v = value(c, &shared);
if !v.is_empty() {
cells.push((at, v));
if v.is_empty() {
continue;
}
if cells.len() >= MAX_COLLECTED_CELLS || text_bytes.saturating_add(v.len()) > MAX_TEXT_BYTES {
cut = true;
break 'rows;
}
text_bytes = text_bytes.saturating_add(v.len());
cells.push((at, v));
}
}
}
let mut warnings = Vec::new();
if cut {
warnings.push(format!(
"the worksheet holds more than {} cells or {} MB of text: the rest was not read",
MAX_COLLECTED_CELLS,
MAX_TEXT_BYTES / (1 << 20)
));
}
if cells.is_empty() {
warnings.push("the worksheet is empty".into());
}
@@ -293,6 +315,50 @@ mod tests {
buf.into_inner()
}
/// A `t="s"` cell materialises its own copy of the shared string, so one big string with many
/// references multiplies: the audit's case is a 1 MB string and 3M cells, terabytes, built
/// before the grid cap applies (#1584). Collection now carries its own byte budget.
#[test]
fn a_shared_string_referenced_many_times_does_not_multiply() {
// A 64 KB shared string referenced 2,000 times would be 128 MB of cell text; the budget
// is 64 MB, so collection stops and says so.
let big = "y".repeat(64 * 1024);
let cells: String = (1..=2_000).map(|r| format!(r#"<row r="{r}"><c r="A{r}" t="s"><v>0</v></c></row>"#)).collect();
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let o = zip::write::SimpleFileOptions::default();
for (name, body) in [
(
"xl/workbook.xml",
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Big" sheetId="1" r:id="rId1"/></sheets></workbook>"#.to_string(),
),
(
"xl/_rels/workbook.xml.rels",
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/></Relationships>"#.to_string(),
),
("xl/sharedStrings.xml", format!("<sst><si><t>{big}</t></si></sst>")),
("xl/worksheets/sheet1.xml", format!("<worksheet><sheetData>{cells}</sheetData></worksheet>")),
] {
z.start_file(name, o).unwrap();
z.write_all(body.as_bytes()).unwrap();
}
z.finish().unwrap();
}
let imp = import(&buf.into_inner()).unwrap();
let table = imp.story.tables.values().next().expect("a table");
let text: usize = (0..table.nrows())
.flat_map(|r| (0..table.ncols()).map(move |c| (r, c)))
.filter_map(|(r, c)| table.cell(r, c).map(|cell| cell.text.text.len()))
.sum();
assert!(text <= 64 << 20, "the table holds at most the budget: {text} bytes");
assert!(imp.warnings.iter().any(|w| w.contains("was not read")), "the report says the sheet was cut: {:?}", imp.warnings);
// An ordinary workbook is unaffected and reports nothing of the sort.
let plain = import(&workbook()).unwrap();
assert!(!plain.warnings.iter().any(|w| w.contains("was not read")), "{:?}", plain.warnings);
}
#[test]
fn first_sheet_used_range_becomes_a_table() {
let imp = import(&workbook()).unwrap();
+9
View File
@@ -356,6 +356,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
Some(_) => Cursor::ResizeV,
None => c,
};
// A ruler guide being dragged keeps the cursor across it until it is dropped, over the
// canvas too (the tool's cursor took over as soon as the pointer left the ruler). Only
// while the button is down: a drag state left behind by a skipped release doesn't stick.
let dragging = ui.input(|i| i.pointer.primary_down());
let c = match ui.data(|d| d.get_temp::<GuideDrag>(egui::Id::unique(GUIDE_DRAG))).filter(|_| dragging).map(|g| g.orientation) {
Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV,
Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH,
None => c,
};
let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p);
ui.ctx().set_cursor_icon(icon);
draw_cursor_badge(&painter, p, c, &t);
@@ -328,3 +328,31 @@ fn a_guide_dragged_out_of_a_ruler_snaps() {
drag(&mut h, ruler, dest);
assert_eq!(positions(&h), [200.0, 300.0, 400.0, 400.0]);
}
/// A guide pulled out of a ruler keeps the resize cursor across it over the canvas until it is
/// dropped; the tool's arrow came back as soon as the pointer left the ruler.
#[test]
fn a_guide_dragged_out_of_a_ruler_keeps_its_cursor_until_dropped() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let mut h = harness(app);
let rect = h.state().canvas_rect.unwrap();
for (from, icon) in [
(pos2(rect.center().x, rect.min.y - crate::canvas::RULER / 2.0), egui::CursorIcon::ResizeVertical),
(pos2(rect.min.x - crate::canvas::RULER / 2.0, rect.center().y), egui::CursorIcon::ResizeHorizontal),
] {
let to = rect.center();
h.hover_at(from);
h.step();
press(&mut h, from, true, Modifiers::NONE);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.step();
assert_eq!(h.output().platform_output.cursor_icon, icon, "step {k} of the drag from {from:?}");
}
press(&mut h, to, false, Modifiers::NONE);
h.hover_at(to);
h.run_steps(2);
assert_ne!(h.output().platform_output.cursor_icon, icon, "dropped: the tool's cursor again");
}
}
+388
View File
@@ -2475,4 +2475,392 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set OpenType Features", "OpenType შესაძლებლობების დაყენება"),
("Swap Text Fill and Stroke", "ტექსტის შევსებისა და კონტურის გაცვლა"),
("Thread Text Frames", "ტექსტური ჩარჩოების დაკავშირება"),
// Command-form parameter names, presets and dialog text.
("Apply to:", "გამოყენება:"),
("Auto-Size", "ავტომატური ზომა"),
("Create Links When Placing Text Files", "ტექსტური ფაილების განთავსებისას ბმულების შექმნა"),
("Kerning/Tracking:", "კერნინგი/ტრეკინგი:"),
("Remove Existing Ruler Guides", "სახაზავის არსებული დამხმარე ხაზების წაშლა"),
("Replace All", "ყველას ჩანაცვლება"),
(
"Replace All updates this font throughout text and styles. Done keeps remaining fallback fonts.",
"„ყველას ჩანაცვლება“ ამ შრიფტს მთელ ტექსტსა და სტილებში განაახლებს. „მზადაა“ დარჩენილ სათადარიგო შრიფტებს შეინარჩუნებს.",
),
("Replace with:", "ჩანაცვლება:"),
("Stroke Behind Fill", "კონტური შევსების უკან"),
("Delete the character style \"{name}\"?", "სიმბოლოს სტილი „{name}“ წაიშალოს?"),
("Delete the paragraph style \"{name}\"?", "აბზაცის სტილი „{name}“ წაიშალოს?"),
("Currently Rendered With", "ამჟამად გამოსახულია შრიფტით"),
(
"“{}” has been edited in this layout. Updating replaces those edits with the text from the file.",
"„{}“ ამ განლაგებაში რედაქტირებულია. განახლება ამ ცვლილებებს ფაილის ტექსტით ჩაანაცვლებს.",
),
("Proportional Metrics", "პროპორციული მეტრიკა"),
("Horizontal Kana", "ჰორიზონტალური კანა"),
("Localized Forms", "ლოკალიზებული ფორმები"),
("Mark Positioning", "დიაკრიტიკული ნიშნების განლაგება"),
("Historical Forms", "ისტორიული ფორმები"),
("Stylistic Alternates", "სტილისტური ალტერნატივები"),
("Justification Alternates", "გასწორების ალტერნატივები"),
("Roman Italics", "რომაული დახრილი"),
("Letter", "Letter"),
("Legal", "Legal"),
("Tabloid", "Tabloid"),
("Letter - Half", "Letter - Half"),
("Postcard", "ღია ბარათი"),
("Web 1920 × 1080", "ვები 1920 × 1080"),
("Web 1366 × 768", "ვები 1366 × 768"),
("Web 1024 × 768", "ვები 1024 × 768"),
("Mobile 1080 × 1920", "მობილური 1080 × 1920"),
("Mobile 390 × 844", "მობილური 390 × 844"),
("Tablet 1024 × 1366", "პლანშეტი 1024 × 1366"),
("Action", "მოქმედება"),
("Adjust layout", "განლაგების მისადაგება"),
("Advance seconds", "გადასვლის ინტერვალი (წმ)"),
("Affix in", "პრეფიქსისა და სუფიქსის ადგილი"),
("Allow", "დაშვება"),
("Anchor", "ღუზა"),
("Artifact", "არტეფაქტი"),
("Asset", "რესურსი"),
("Assets", "რესურსები"),
("At end", "ბოლოში"),
("Attrs", "ატრიბუტები"),
("Author", "ავტორი"),
("Auto fit", "ავტომორგება"),
("Auto size", "ავტომატური ზომა"),
("Auto size reference", "ავტომატური ზომის საყრდენი წერტილი"),
("Axis", "ღერძი"),
("Bands", "ზოლები"),
("Bookmarks panel", "სანიშნეების პანელი"),
("Case", "რეგისტრი"),
("Column gutter", "სვეტებს შორის შუალედი"),
("Column width", "სვეტის სიგანე"),
("Columns kind", "სვეტების ტიპი"),
("Continue across stories", "გაგრძელება ტექსტურ ნაკადებს შორის"),
("Copies", "ასლები"),
("Corners", "კუთხეები"),
("Cover image", "ყდის გამოსახულება"),
("Definition", "განსაზღვრება"),
("Dir", "საქაღალდე"),
("Dry run", "საცდელი გაშვება"),
("Embed fonts", "შრიფტების ჩაშენება"),
("End", "დასასრული"),
("Extension", "გაფართოება"),
("Facing pages", "მოპირდაპირე გვერდები"),
("Family", "შრიფტის ოჯახი"),
("First baseline", "პირველი საბაზისო ხაზი"),
("First baseline min", "პირველი საბაზისო ხაზის მინიმუმი"),
("Fit to", "მორგება"),
("Fixed layout", "ფიქსირებული განლაგება"),
("Flatten", "გაბრტყელება"),
("Font size", "შრიფტის ზომა"),
("Format", "ფორმატი"),
("From", "საიდან"),
("Full screen", "სრული ეკრანი"),
("Grep", "GREP"),
("Heading", "სათაური"),
("Heading style", "სათაურის სტილი"),
("Hidden", "დამალული"),
("Identifier", "იდენტიფიკატორი"),
("Idml", "IDML"),
("Ids", "ID-ები"),
("Include inside edges", "შიდა კიდეების ჩართვა"),
("Include prefix", "პრეფიქსის ჩართვა"),
("Instructions", "ინსტრუქციები"),
("Intent", "განზრახვა"),
("Keep within column", "სვეტის ფარგლებში დატოვება"),
("Kind", "სახეობა"),
("Label", "იარლიყი"),
("Latex", "LaTeX"),
("Layout page", "განლაგების გვერდი"),
("Line breaks", "სტრიქონის წყვეტები"),
("Load cursor", "კურსორში ჩატვირთვა"),
("Marker", "მარკერი"),
("Marks", "ნიშნები"),
("Message", "შეტყობინება"),
("Minimum height", "მინიმალური სიმაღლე"),
("Minimum width", "მინიმალური სიგანე"),
("Missing only", "მხოლოდ დაკარგული"),
("Mode", "რეჟიმი"),
("Number", "ნომერი"),
("Object point", "ობიექტის საყრდენი წერტილი"),
("Org", "ორგანიზაცია"),
("Page break before", "გვერდის გამყოფი წინ"),
("Page layout", "გვერდის განლაგება"),
("Para style", "აბზაცის სტილი"),
("Paragraphs", "აბზაცები"),
("Parent", "საბაზო გვერდი"),
("Pattern", "პატერნი"),
("Pdf", "PDF"),
("Pdf crop", "PDF-ის ჩამოჭრა"),
("Pdf page", "PDF-ის გვერდი"),
("Phone", "ტელეფონი"),
("Pos", "პოზიცია"),
("Prefix", "პრეფიქსი"),
("Primary text frame", "ძირითადი ტექსტური ჩარჩო"),
("Printer", "პრინტერი"),
("Rasterize", "რასტრირება"),
("Rect", "მართკუთხედი"),
("Redefine styles", "სტილების ხელახლა განსაზღვრა"),
("Ref", "საყრდენი წერტილი"),
("Ref char style", "მიმართვის სიმბოლოს სტილი"),
("Ref position", "მიმართვის პოზიცია"),
("Remove existing", "არსებულის წაშლა"),
("Restart", "თავიდან დაწყება"),
("Row gutter", "სტრიქონებს შორის შუალედი"),
("Rule", "წესი"),
("Run in", "ერთ აბზაცში"),
("Scope", "მოქმედების არე"),
("Section headings", "სექციის სათაურები"),
("Separator", "გამყოფი"),
("Shape", "ფიგურა"),
("Space", "ფერთა სივრცე"),
("Space after", "დაშორება შემდეგ"),
("Space before", "დაშორება წინ"),
("Space between", "დაშორება შორის"),
("Span columns", "სვეტებზე გაშლა"),
("Start", "დასაწყისი"),
("Start at", "დაწყება"),
("Start number", "საწყისი ნომერი"),
("Start page", "საწყისი გვერდი"),
("Subject", "თემა"),
("Subpath", "ქვეტრაექტორია"),
("Suffix", "სუფიქსი"),
("Suggestions", "შეთავაზებები"),
("Tagged", "ტეგირებული"),
("Target", "სამიზნე"),
("Threshold", "ზღვარი"),
("Title", "დასახელება"),
("To family", "ახალი შრიფტის ოჯახი"),
("To spread", "სამიზნე გაშლა"),
("To style", "ახალი სტილი"),
("Toc title", "სარჩევის სათაური"),
("Tolerance", "დაშვება"),
("Topics", "თემები"),
("Url", "URL"),
("Use", "გამოყენება"),
("Use minimum height", "მინიმალური სიმაღლის გამოყენება"),
("Use minimum width", "მინიმალური სიგანის გამოყენება"),
("Value", "მნიშვნელობა"),
("Visible", "ხილული"),
("Word", "სიტყვა"),
("X offset", "X წანაცვლება"),
("X relative", "X-ის ათვლის წერტილი"),
("Y offset", "Y წანაცვლება"),
(" Save ", " შენახვა "),
(" Update ", " განახლება "),
// Keyboard shortcut sets (craft-keymap), docking and new commands.
("(cycles)", "(ციკლურად)"),
("(modified)", "(შეცვლილი)"),
("+Bin", "+Bin"),
("+LOC", "+LOC"),
("A command can have at most {n} shortcuts", "ბრძანების მალსახმობების მაქსიმალური რაოდენობა: {n}"),
("A set with this name exists", "ამ სახელის ნაკრები უკვე არსებობს"),
("Add a shortcut to {label}", "{label}: მალსახმობის დამატება"),
("All Commands", "ყველა ბრძანება"),
("Also used by {label}", "ასევე იყენებს: {label}"),
("Alt", "Alt"),
("Application", "აპლიკაცია"),
("Approximated", "მიახლოებით წაკითხული"),
("Assign", "მინიჭება"),
("Assign to Selected", "მონიშნულზე მინიჭება"),
("Assign {chord} to {label}", "{label}: მალსახმობის მინიჭება ({chord})"),
("Auto-Collapse Iconic Panels", "ხატულებად ჩაკეცილი პანელების ავტომატური ჩაკეცვა"),
("Based on:", "საფუძველი:"),
("Built-in sets cannot be changed", "ჩაშენებული ნაკრებების შეცვლა შეუძლებელია"),
("Cancel Import", "იმპორტის გაუქმება"),
("Cancel the new shortcut", "ახალი მალსახმობის გაუქმება"),
("Caps Lock", "Caps Lock"),
("Changed", "შეცვლილი"),
("Check for Updates…", "განახლებების შემოწმება…"),
("Choose a command…", "აირჩიეთ ბრძანება…"),
("Click a key to see its shortcuts", "დაწკაპეთ კლავიშზე მისი მალსახმობების სანახავად"),
("Close Other Panels in Group", "ჯგუფის სხვა პანელების დახურვა"),
("Close panel window", "პანელის ფანჯრის დახურვა"),
("Command", "ბრძანება"),
("Command for {item}", "ბრძანება ელემენტისთვის „{item}“"),
("Commands", "ბრძანებები"),
("Conflicts", "კონფლიქტები"),
("Context:", "კონტექსტი:"),
("Copy Report", "ანგარიშის კოპირება"),
("Ctrl", "Ctrl"),
("Current shortcuts", "მიმდინარე მალსახმობები"),
("Current shortcuts:", "მიმდინარე მალსახმობები:"),
("Currently assigned to:", "ამჟამად მინიჭებულია:"),
("Default: {chord}", "ნაგულისხმევი: {chord}"),
("Delete Parent", "საბაზო გვერდის წაშლა"),
("Delete Surrounding Text", "მიმდებარე ტექსტის წაშლა"),
("Delete the set “{set}”?", "ნაკრები „{set}“ წაიშალოს?"),
("Deselect Guides", "დამხმარე ხაზების მონიშვნის მოხსნა"),
("Detail pane size", "დეტალების არის ზომა"),
("EPUB Split Document", "EPUB: დოკუმენტის დაყოფა"),
("Expand", "გაშლა"),
("Expand Panels", "პანელების გაშლა"),
("Export…", "ექსპორტი…"),
("File Info", "ფაილის ინფორმაცია"),
("File Info…", "ფაილის ინფორმაცია…"),
("Filter:", "ფილტრი:"),
("Fixed Keys", "ფიქსირებული კლავიშები"),
("Float Tab Group", "მცურავი ჩანართების ჯგუფი"),
("Go to Conflict", "კონფლიქტზე გადასვლა"),
("Group Tab Group with", "ჩანართების ჯგუფის ერთად დაჯგუფება"),
("Import", "იმპორტი"),
("Import InDesign Shortcut Set", "InDesign-ის მალსახმობების ნაკრების იმპორტი"),
("Import as New Set", "იმპორტი ახალ ნაკრებად"),
("Import “{set}”", "იმპორტი: „{set}“"),
("Imported", "იმპორტირებული"),
("Imported as “{set}”", "იმპორტირებულია როგორც „{set}“"),
("Import…", "იმპორტი…"),
("Keep Both", "ორივეს შენარჩუნება"),
("Key: {key}", "კლავიში: {key}"),
("Keyboard", "კლავიატურა"),
("Keyboard Shortcuts (JSON)", "კლავიატურის მალსახმობები (JSON)"),
("Keyboard: US", "კლავიატურა: US"),
("Keyboard: US (macOS)", "კლავიატურა: US (macOS)"),
("Lock Workspace", "სამუშაო სივრცის ჩაკეტვა"),
("Matched without change", "დაემთხვა ცვლილების გარეშე"),
("Maximize Frame", "ჩარჩოს გაშლა"),
("Modifier on", "მოდიფიკატორი ჩართულია"),
("More", "მეტი"),
("Move Guides", "დამხმარე ხაზების გადაადგილება"),
("Move Panel to New Window", "პანელის ახალ ფანჯარაში გადატანა"),
("Move panel column", "პანელების სვეტის გადაადგილება"),
("Move panel window", "პანელის ფანჯრის გადაადგილება"),
("Move tab group", "ჩანართების ჯგუფის გადაადგილება"),
("New Set…", "ახალი ნაკრები…"),
("New shortcut:", "ახალი მალსახმობი:"),
("New shortcut: {chord}", "ახალი მალსახმობი: {chord}"),
("Not assigned", "არ არის მინიჭებული"),
("Not changed", "არ შეცვლილა"),
("Not matched", "შესაბამისობა ვერ მოიძებნა"),
("Nothing uses this key", "ამ კლავიშს არაფერი იყენებს"),
("Now used by {label}", "ახლა იყენებს: {label}"),
("Option", "Option"),
("PDF/X-1a:2003", "PDF/X-1a:2003"),
("PRs", "PR-ები"),
("Panel or context", "პანელი ან კონტექსტი"),
("Remove {chord} from {label}", "{label}: მალსახმობის წაშლა ({chord})"),
("Rename…", "სახელის შეცვლა…"),
("Reset All", "ყველაფრის ჩამოყრა"),
("Reset All…", "ყველაფრის ჩამოყრა…"),
("Restore Frame Size", "ჩარჩოს ზომის აღდგენა"),
("Restore every default shortcut in this set?", "აღდგეს ამ ნაკრების ყველა ნაგულისხმევი მალსახმობი?"),
("Restore the default for {label}", "{label}: ნაგულისხმევის აღდგენა"),
("Revert Set", "ნაკრების წინა ვერსიაზე დაბრუნება"),
("Ruler Zero Point", "სახაზავის ნულოვანი წერტილი"),
("Save changes to “{set}”?", "შეინახოს ცვლილებები ნაკრებში „{set}“?"),
("Search by key", "ძიება კლავიშით"),
("Select Guide", "დამხმარე ხაზის მონიშვნა"),
("Select a command first", "ჯერ მონიშნეთ ბრძანება"),
("Select a command to see and change its shortcuts", "მონიშნეთ ბრძანება მისი მალსახმობების სანახავად და შესაცვლელად"),
("Set name:", "ნაკრების სახელი:"),
("Set:", "ნაკრები:"),
("Shift", "Shift"),
("Shortcut", "მალსახმობი"),
("Shortcut Sets", "მალსახმობების ნაკრებები"),
("Show:", "ჩვენება:"),
("Skipped", "გამოტოვებული"),
("Story Options", "ტექსტური ნაკადის პარამეტრები"),
("Suggestions ({n})", "შეთავაზებები ({n})"),
("Summarize…", "შეჯამება…"),
("Super", "Super"),
("Take It", "აღება"),
("Take It and Go to Conflict", "აღება და კონფლიქტზე გადასვლა"),
("Taken from other commands", "სხვა ბრძანებებიდან აღებული"),
("The file is too large", "ფაილი ძალიან დიდია"),
("The name is empty, too long or has control characters", "სახელი ცარიელია, ძალიან გრძელია ან მმართველ სიმბოლოებს შეიცავს"),
("The scope {scope} is unknown", "მოქმედების არე {scope} უცნობია"),
("The set was not found", "ნაკრები ვერ მოიძებნა"),
("There are too many sets", "ნაკრები ძალიან ბევრია"),
("This command is unknown", "ეს ბრძანება უცნობია"),
("This file is from a newer version (format {n})", "ეს ფაილი უფრო ახალი ვერსიისაა (ფორმატი {n})"),
("This is not a craft-keymap file", "ეს craft-keymap ფაილი არ არის"),
("This is not a shortcut set file", "ეს მალსახმობების ნაკრების ფაილი არ არის"),
("Too many shortcuts are assigned", "მინიჭებულია ძალიან ბევრი მალსახმობი"),
("Tools", "ხელსაწყოები"),
("Transparency Flattener", "გამჭვირვალობის გაბრტყელება"),
("Type…", "ტექსტი…"),
("Unreadable", "წაუკითხავი"),
("Use Default", "ნაგულისხმევის გამოყენება"),
("Will change", "შეიცვლება"),
("Win", "Win"),
("With Shortcut", "მალსახმობის მქონე"),
("empty shortcut", "ცარიელი მალსახმობი"),
("in a context that is not imported", "კონტექსტში, რომელიც არ იმპორტირდება"),
("key sequences are not supported", "კლავიშთა მიმდევრობები მხარდაჭერილი არ არის"),
("line {n}", "სტრიქონი {n}"),
("modifier {key} given twice", "მოდიფიკატორი {key} ორჯერაა მითითებული"),
("more than one command fits", "ერთზე მეტი ბრძანება შეესაბამება"),
("no command has this name", "ამ სახელის ბრძანება არ არსებობს"),
("numeric keypad key read as main key", "ციფრული ბლოკის კლავიში წაკითხულია ძირითად კლავიშად"),
("only a command of the other kind fits (tool or menu command)", "შეესაბამება მხოლოდ სხვა სახეობის ბრძანება (ხელსაწყო ან მენიუს ბრძანება)"),
("press keys", "დააჭირეთ კლავიშებს"),
("reserved by the system", "სისტემისთვისაა დაჯავშნული"),
("taken by a later entry", "დაიკავა მომდევნო ჩანაწერმა"),
("taken by {label}", "დაიკავა: {label}"),
("the scope is unknown", "მოქმედების არე უცნობია"),
("the scope name is not valid", "მოქმედების არის სახელი არასწორია"),
("the set's shortcuts could not be added", "ნაკრების მალსახმობების დამატება ვერ მოხერხდა"),
("the shortcut has no key", "მალსახმობს კლავიში არ აქვს"),
("the shortcut is too long", "მალსახმობი ძალიან გრძელია"),
("the shortcuts could not be removed", "მალსახმობების წაშლა ვერ მოხერხდა"),
("unknown key {key}", "უცნობი კლავიში {key}"),
("unknown modifier {key}", "უცნობი მოდიფიკატორი {key}"),
("unsupported modifier {key}", "მხარდაუჭერელი მოდიფიკატორი {key}"),
("{chord} (Default)", "{chord} (ნაგულისხმევი)"),
("{chord} (press again for a double press)", "{chord} (ორმაგი დაჭერისთვის კვლავ დააჭირეთ)"),
("{chord} ({scope})", "{chord} ({scope})"),
("{chord} is reserved by the system", "{chord} სისტემისთვისაა დაჯავშნული"),
("{chord} was removed from {label}", "{label}: მალსახმობი {chord} წაიშალა"),
("{chord}: {label}", "{chord}: {label}"),
("{key}: listed more than once", "{key}: ერთზე მეტჯერაა მითითებული"),
("{key}: no command has this name", "{key}: ამ სახელის ბრძანება არ არსებობს"),
("{key}: part of the entry was dropped", "{key}: ჩანაწერის ნაწილი გამოტოვებულია"),
("{key}: the scope in “{text}” is unknown", "{key}: მოქმედების არე უცნობია („{text}“)"),
("{key}: {text} is reserved by the system", "{key}: {text} სისტემისთვისაა დაჯავშნული"),
("{key}: “{text}” is not a shortcut ({error})", "{key}: „{text}“ მალსახმობი არ არის ({error})"),
("{label} (Changed)", "{label} (შეცვლილი)"),
("{label} (cycles)", "{label} (ციკლურად)"),
("{label} ({scope})", "{label} ({scope})"),
("{label} ({scope}, both work)", "{label} ({scope}, ორივე მუშაობს)"),
("{label} keeps its shortcuts (matched by a panel menu name)", "{label} მალსახმობებს ინარჩუნებს (დაემთხვა პანელის მენიუს სახელით)"),
("{label}: press keys", "{label}: დააჭირეთ კლავიშებს"),
("{label}: {chord}", "{label}: {chord}"),
("{label}: {chord} (Now used by {holder})", "{label}: {chord} (ახლა იყენებს: {holder})"),
("{menu} Menu", "მენიუ „{menu}“"),
("{n} command changed", "შეცვლილი ბრძანებები: {n}"),
("{n} command will change", "შესაცვლელი ბრძანებები: {n}"),
("{n} commands changed", "შეცვლილი ბრძანებები: {n}"),
("{n} commands will change", "შესაცვლელი ბრძანებები: {n}"),
("{n} entries cut at a limit", "ლიმიტზე შეკვეცილი ჩანაწერები: {n}"),
("{n} entries were ignored: too many commands", "უგულებელყოფილი ჩანაწერები (ბრძანება ძალიან ბევრია): {n}"),
("{n} entry cut at a limit", "ლიმიტზე შეკვეცილი ჩანაწერები: {n}"),
("{n} entry was ignored: too many commands", "უგულებელყოფილი ჩანაწერები (ბრძანება ძალიან ბევრია): {n}"),
("{n} item could not be read", "ელემენტები, რომელთა წაკითხვა ვერ მოხერხდა: {n}"),
("{n} item not matched", "ელემენტები შესაბამისობის გარეშე: {n}"),
("{n} item without a shortcut", "ელემენტები მალსახმობის გარეშე: {n}"),
("{n} items could not be read", "ელემენტები, რომელთა წაკითხვა ვერ მოხერხდა: {n}"),
("{n} items not matched", "ელემენტები შესაბამისობის გარეშე: {n}"),
("{n} items without a shortcut", "ელემენტები მალსახმობის გარეშე: {n}"),
("{n} key read approximately", "მიახლოებით წაკითხული კლავიშები: {n}"),
("{n} keys read approximately", "მიახლოებით წაკითხული კლავიშები: {n}"),
("{n} saved shortcut could not be used", "შენახული მალსახმობები, რომელთა გამოყენება ვერ მოხერხდა: {n}"),
("{n} saved shortcuts could not be used", "შენახული მალსახმობები, რომელთა გამოყენება ვერ მოხერხდა: {n}"),
("{n} shortcut also works in another scope", "სხვა მოქმედების არეშიც მოქმედი მალსახმობები: {n}"),
("{n} shortcut is assigned more than once", "ერთზე მეტჯერ მინიჭებული მალსახმობები: {n}"),
("{n} shortcuts also work in another scope", "სხვა მოქმედების არეშიც მოქმედი მალსახმობები: {n}"),
("{n} shortcuts are assigned more than once", "ერთზე მეტჯერ მინიჭებული მალსახმობები: {n}"),
("{panel} Panel", "პანელი „{panel}“"),
("{title} ({n})", "{title} ({n})"),
("{title}: {set}", "{title}: {set}"),
("ΔLOC", "ΔLOC"),
("−Bin", "−Bin"),
("−LOC", "−LOC"),
("⚠ {chord} is used by {label}", "⚠ {chord} დაკავებულია: {label}"),
("Y:", "Y:"),
("Show the Typography panel", "ტიპოგრაფიის პანელის ჩვენება"),
(
"This story was edited in the layout: you'll be asked before those edits are replaced",
"ეს ტექსტური ნაკადი განლაგებაში რედაქტირებულია: ამ ცვლილებების ჩანაცვლებამდე დაგეკითხებით",
),
];
+1 -1
View File
@@ -156,7 +156,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
- **Estimate:** 25–40 h.
### 13. Hyphenation and spelling languages (T, L)
- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian and Greek; spelling for
- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian, Greek and German (2006 reform); spelling for
every language but English. InDesign ships dictionaries for ~40 languages.
- **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need
licence review). **Doc:** [typography-parity.md](typography-parity.md)
+5 -2
View File
@@ -40,7 +40,7 @@ come as Middle East editions with an English or French interface and right-to-le
| Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; Portuguese hyphenation ✓ | no | partial | 8–12 |
| Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 |
| Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); canvas IME ✓, not checked by hand (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME checks, shared with zh) |
| German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no German hyphenation (InDesign ships 2006 reform patterns) | no | partial | 6–10 |
| German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; German 2006 hyphenation ✓ (hyph-de-1996, MIT) | no | partial | 6–10 |
| Korean | ko | ✓ | 0 (0%) | none | Korean line breaking at spaces ✓; canvas IME ✓, not checked by hand (#322) | no | none | 15–25 |
| Vietnamese | vi | ✗ | 0 (0%) | none | Latin with stacked diacritics via harfrust, untested | no | none | 12–18 |
@@ -68,9 +68,12 @@ eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv,
## Document languages
InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries).
DesignCraft hyphenates **English, Spanish, Portuguese and Norwegian** and spell-checks **English** only. Text language
DesignCraft hyphenates **English, Spanish, Portuguese, Norwegian, Greek and German (2006 reform)** and spell-checks **English** only. Text language
can be set per paragraph and character (Properties, Character panel) and drives quotes and
locale shaping. See [typography-parity.md](typography-parity.md).
German hyphenation accepts `German: 2006 Reform`, `German: Swiss 2006 Reform` and `de` locale
codes without a spelling year or with `2006`. Traditional names, `de_DE_1901` and unknown
German variants have no automatic hyphenation; their German shaping and quotes are unchanged.
## Remaining effort
+2 -2
View File
@@ -59,7 +59,7 @@ they receive, setting type exactly, and basic editing that never fails.
| Dimension | Weight | Ready | Remaining (Opus 5.5 h) | Evidence | Doc |
|---|---|---|---|---|---|
| Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file |
| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese, Norwegian and Greek only | [typography-parity.md](typography-parity.md) |
| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese, Norwegian, Greek and German 2006 only | [typography-parity.md](typography-parity.md) |
| File formats | 20% | 40% | 150–250 (+10–30 for an INDD corpus) | INDD and INDT open through indd-utils, measured on synthetic bytes only (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) |
| UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165) failing | [ui-parity.md](ui-parity.md) |
| Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) |
@@ -362,7 +362,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive), all through the `hypher` crate; hyphenation for other languages isn't bundled yet.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive), all through the `hypher` crate; Greek uses `hyph-el-monoton.tex` (MIT). German uses hyph-utf8's `hyph-de-1996.tex` 2024-02-28 (MIT), which contains the reformed 2006 patterns despite its filename. Only the explicit 2006 reform names and plain or 2006 German locale codes use this set; `German: Traditional`, `de_DE_1901` and unknown German variants have no automatic hyphenation. No separate old-spelling or Swiss set is bundled. Hyphenation for other languages isn't bundled yet.
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, chapter numbers resolved in book order (Automatic, Start Chapter Numbering at, Same as Previous Document; `layout.chapterNumbering`, with a style for Chapter Number variables and cross-references), style sync from a style source, one merged PDF. Section prefixes follow InDesign's rule (up to 8 characters, no + or comma).
- Numbering & Section Options window as in InDesign (Start Section, automatic or explicit page numbering up to 99999, prefix, style, marker, Include Prefix; the document's chapter numbering and its book); OK applies both in one undo step (`layout.numberingOptions`).
- Book numbering/style updates validate and serialize all targets before changing the first chapter. They write only closed native chapters, checking canonical paths for open aliases; IDML remains a read-only style source and PDF input. Snapshots are staged on disk with at most 4,096 chapter records in memory. Each replacement is atomic; a later write failure explicitly lists completed chapters rather than claiming whole-book rollback. Import/serialization memory limits and advanced book TOC/index work remain separate.
+2 -2
View File
@@ -1,6 +1,6 @@
# Typography and composition parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Optical Margin Alignment per story with a size in the Story panel, and Ignore Optical Margin in the Paragraph panel menu and paragraph styles, kept through IDML (#947); Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (German 2006 hyphenation; Optical Margin Alignment per story with a size in the Story panel, and Ignore Optical Margin in the Paragraph panel menu and paragraph styles, kept through IDML (#947); Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6)
Typesetting is what InDesign is bought for. A feature can exist and still set text differently
from InDesign: different line breaks, different page count, overset where InDesign fits. This
@@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated.
| Single-line Composer | greedy | greedy with the same ranges | ✓ | |
| World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested |
| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) |
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton) and German 2006 (hyph-de-1996); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | |
| Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently |
| Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | |
@@ -27,6 +27,14 @@ finish-args:
- --share=network
# GPU access for egui's renderer (Vulkan, or GL as a fallback).
- --device=dri
# Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and
# find driverless network printers through Avahi (`lpstat -e`). Without these the Print
# dialog lists no printers.
- --socket=cups
- --system-talk-name=org.freedesktop.Avahi
# The user's CUPS settings, read-only: their default printer and saved options (lpoptions)
# and a print server named in their client.conf, which lp reaches over the network.
- --filesystem=~/.cups:ro
# Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend)
# and drag-and-drop through the document portal, so picked files work anywhere without broad
# filesystem access. Documents and Pictures are granted so files passed by path (the command
@@ -35,6 +43,50 @@ finish-args:
- --filesystem=xdg-pictures
# Home/host filesystem access is deliberately not granted.
modules:
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
# Linked statically against this release's libcups, so a runtime update can't break them;
# the socket path matches the host's, which --socket=cups mounts.
- name: cups-client
buildsystem: simple
build-commands:
- ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups
--with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups
--with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus
- make -C cups libs
- make -C systemv lp lpoptions lpstat
- install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat
- for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done
- install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE
sources:
- type: archive
url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz
sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff
# Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing
# it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled
# exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing
# when that socket is missing. CUPS reads the keyword in any case and takes the last
# ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at
# the mount; when it isn't, the tool fails rather than reach a different server. A
# CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted.
- type: script
dest-filename: cups-socket.sh
commands:
- 'conf="$HOME/.cups/client.conf"'
- 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then'
- ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)'
- ' case "$server" in'
- ' /*)'
- ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')'
- ' if [ "$mounted" = "$server" ]; then'
- ' export CUPS_SERVER=/var/run/cups/cups.sock'
- ' else'
- ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2'
- ' exit 1'
- ' fi'
- ' ;;'
- ' esac'
- 'fi'
- 'exec "/app/libexec/cups/${0##*/}" "$@"'
- name: designcraft
buildsystem: simple
build-commands:
@@ -28,6 +28,14 @@ finish-args:
- --share=network
# GPU access for egui's renderer (Vulkan, or GL as a fallback).
- --device=dri
# Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and
# find driverless network printers through Avahi (`lpstat -e`). Without these the Print
# dialog lists no printers.
- --socket=cups
- --system-talk-name=org.freedesktop.Avahi
# The user's CUPS settings, read-only: their default printer and saved options (lpoptions)
# and a print server named in their client.conf, which lp reaches over the network.
- --filesystem=~/.cups:ro
# Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend)
# and drag-and-drop through the document portal, so picked files work anywhere without broad
# filesystem access. Documents and Pictures are granted so files passed by path (the command
@@ -38,6 +46,50 @@ finish-args:
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules:
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
# Linked statically against this release's libcups, so a runtime update can't break them;
# the socket path matches the host's, which --socket=cups mounts.
- name: cups-client
buildsystem: simple
build-commands:
- ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups
--with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups
--with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus
- make -C cups libs
- make -C systemv lp lpoptions lpstat
- install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat
- for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done
- install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE
sources:
- type: archive
url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz
sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff
# Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing
# it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled
# exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing
# when that socket is missing. CUPS reads the keyword in any case and takes the last
# ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at
# the mount; when it isn't, the tool fails rather than reach a different server. A
# CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted.
- type: script
dest-filename: cups-socket.sh
commands:
- 'conf="$HOME/.cups/client.conf"'
- 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then'
- ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)'
- ' case "$server" in'
- ' /*)'
- ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')'
- ' if [ "$mounted" = "$server" ]; then'
- ' export CUPS_SERVER=/var/run/cups/cups.sock'
- ' else'
- ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2'
- ' exit 1'
- ' fi'
- ' ;;'
- ' esac'
- 'fi'
- 'exec "/app/libexec/cups/${0##*/}" "$@"'
- name: designcraft
buildsystem: simple
build-options:
+34
View File
@@ -6,6 +6,25 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<!-- UI translations are embedded in Rust; declare them for AppKit's system dialogs (open and
save panels, overwrite confirmation). Keep in sync with the interface languages. -->
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>cs</string>
<string>de</string>
<string>es</string>
<string>fr</string>
<string>it</string>
<string>ja</string>
<string>ka</string>
<string>pl</string>
<string>pt-BR</string>
<string>ru</string>
<string>uk</string>
<string>zh-Hans</string>
</array>
<key>CFBundleDisplayName</key>
<string>DesignCraft</string>
<key>CFBundleName</key>
@@ -72,6 +91,21 @@
<key>LSItemContentTypes</key>
<array><string>ai.storyteller.designcraft.idml</string></array>
</dict>
<!-- The same, by extension. When another installed app (InDesign) exports its own type for
.idml, files get that type instead of the one imported below, so the entry above no
longer matches them: Finder then won't drop them on the app's icon, and Open With leaves
DesignCraft out. An extension entry matches whatever type the file has. It must be a
dict of its own: where LSItemContentTypes is present, the extension keys are ignored. -->
<dict>
<key>CFBundleTypeName</key>
<string>IDML Document (by extension)</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeExtensions</key>
<array><string>idml</string></array>
</dict>
</array>
<!-- DesignCraft's own format: a zip package, declared as a document rather than an archive so