mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Merge storytold/main into feat/save-dialog-folder
This commit is contained in:
@@ -11,7 +11,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
|
||||
+5
-1
@@ -42,7 +42,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
|
||||
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
|
||||
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads frames on the pages that follow, then adds pages at the end of the document until the text fits; Remove Styles option (`designcraft-textimport`).
|
||||
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
|
||||
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
|
||||
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
|
||||
@@ -301,6 +301,8 @@ measured); Ready estimated.
|
||||
## Languages
|
||||
|
||||
Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
A string without a translation shows in English and fails no check; `cargo xtask i18n-report`
|
||||
(also a `cargo xtask ci` step) lists the missing strings per language.
|
||||
|
||||
| Language | Code | Status | Translated |
|
||||
|---|---|---|---|
|
||||
@@ -349,7 +351,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
Newest first.
|
||||
|
||||
- **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-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** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
|
||||
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
|
||||
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
|
||||
|
||||
@@ -679,10 +679,12 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
||||
let abs = doc.first_page_of_spread(si) + pi;
|
||||
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left))
|
||||
}
|
||||
(Some(designcraft_doc::SpreadRef::Parent(pi)), Some(sp)) => {
|
||||
(Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => {
|
||||
let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into());
|
||||
let _ = pi;
|
||||
(Some(prefix), None, false)
|
||||
// The side of the parent page under the frame, so a folio there aligns to the spine of its own page.
|
||||
let under = sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi));
|
||||
let left = under.is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
|
||||
(Some(prefix), None, left)
|
||||
}
|
||||
_ => (None, None, false),
|
||||
};
|
||||
|
||||
@@ -7310,3 +7310,16 @@ fn rule_above_offset_is_measured_from_the_baseline() {
|
||||
assert!((rule.height() - 1.5).abs() < 1e-6);
|
||||
assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_page_frames_know_which_side_of_the_spine_they_are_on() {
|
||||
// Folios on a facing-pages parent: towards/away from spine must differ between its left and right page.
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let w = d.parents[0].pages[0].width;
|
||||
let para = ParaFormat { para: ParaAttrs { align: Some(Align::TowardsSpine), ..Default::default() }, ..Default::default() };
|
||||
let (_, left_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(36.0, 700.0, w - 36.0, 730.0), lid, "7", para.clone()).unwrap();
|
||||
let (_, right_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(w + 36.0, 700.0, 2.0 * w - 36.0, 730.0), lid, "8", para).unwrap();
|
||||
assert!(frame_specs(&d, left_story)[0].left_page);
|
||||
assert!(!frame_specs(&d, right_story)[0].left_page);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
|
||||
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
|
||||
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})",
|
||||
has_doc, export_pdf),
|
||||
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
|
||||
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
|
||||
@@ -371,6 +371,10 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
|
||||
Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?,
|
||||
None => Standard::None,
|
||||
};
|
||||
let created = match p.get("created") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?),
|
||||
};
|
||||
Ok(PdfOptions {
|
||||
pages,
|
||||
spreads,
|
||||
@@ -382,6 +386,7 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
|
||||
author: str_param(p, "author").map(str::to_string),
|
||||
tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false),
|
||||
media: p.get("media").and_then(Value::as_bool).unwrap_or(false),
|
||||
created,
|
||||
..PdfOptions::default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,11 +233,12 @@ fn same_rect(a: Rect, b: Rect) -> bool {
|
||||
}
|
||||
|
||||
/// Thread new frames until the story fits: first into the columns after the last frame's column
|
||||
/// on its page (when it sits in one), then one frame per column on each page it adds. With
|
||||
/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
|
||||
/// live area with the page's columns instead. Empty frames it made at the end of the thread are
|
||||
/// removed again, except the first on a page it didn't add; a page it added that holds only such
|
||||
/// a frame goes with it. Returns pages added.
|
||||
/// on its page (when it sits in one), then one frame per column on each page that follows it,
|
||||
/// whatever else the page holds, and on each page it adds at the end of the document, as InDesign
|
||||
/// does. With `primary` (Smart Text Reflow of the primary text frame), pages are added right after
|
||||
/// the last frame's page, each with one frame on the live area with the page's columns instead.
|
||||
/// Empty frames it made at the end of the thread are removed again; a page it added that holds
|
||||
/// only such a frame goes with it. Returns pages added.
|
||||
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
|
||||
/// name `cmd`, the command that autoflows.
|
||||
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result<usize> {
|
||||
@@ -252,6 +253,8 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
let mut made = Vec::new();
|
||||
let mut new_pages = Vec::new();
|
||||
let mut first = true;
|
||||
// The next page of the document autoflow may set frames on; it only moves forward.
|
||||
let mut following = 0;
|
||||
loop {
|
||||
let (overset, held, full) = fit(d, sid);
|
||||
let Some(at) = overset else { break };
|
||||
@@ -267,16 +270,33 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
let rects = flow_rects(d, dir, vertical, page);
|
||||
let next = d.item(last).and_then(|it| rects.iter().position(|r| same_rect(*r, it.bounds()))).map_or(rects.len(), |k| k + 1);
|
||||
if let (Some(rest), Some((si, _))) = (rects.get(next..).filter(|r| !r.is_empty()), d.page_loc(page)) {
|
||||
for r in rest.iter().take(want) {
|
||||
// Whole pages: an estimate that stops inside one would leave its later columns out
|
||||
// of the thread for good. The cleanup below takes the frames the text doesn't reach.
|
||||
for r in rest {
|
||||
prev = add_flow_frame(d, si, *r, layer, prev)?;
|
||||
made.push(prev);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
following = following.max(page + 1);
|
||||
if !primary && following < d.page_count() {
|
||||
let mut left = want;
|
||||
while left > 0 && following < d.page_count() {
|
||||
let Some((si, _)) = d.page_loc(following) else { break };
|
||||
for r in flow_rects(d, dir, vertical, following) {
|
||||
prev = add_flow_frame(d, si, r, layer, prev)?;
|
||||
made.push(prev);
|
||||
left = left.saturating_sub(1);
|
||||
}
|
||||
following += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if added >= max_pages {
|
||||
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
|
||||
}
|
||||
// Past the following pages, `page` is the document's last: autoflow adds pages at its end.
|
||||
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
|
||||
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
|
||||
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
|
||||
@@ -304,16 +324,23 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
}
|
||||
let page = d.page_of_item(f);
|
||||
if d.page_of_item(*before) != page {
|
||||
// The first frame on its page: the estimate added more pages than the text needs.
|
||||
// Drop the page too when autoflow added it and the frame is all it holds.
|
||||
let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
|
||||
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
|
||||
if !alone {
|
||||
break;
|
||||
// The first frame on its page: the estimate asked for more pages than the text needs.
|
||||
// Drop the page too when autoflow added it and the frame is all it holds; a page the
|
||||
// document had keeps whatever else is on it.
|
||||
match page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) {
|
||||
Some(pg) => {
|
||||
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
|
||||
if !alone {
|
||||
break;
|
||||
}
|
||||
d.remove_item(f)?;
|
||||
d.delete_pages(&[pg])?;
|
||||
added = added.saturating_sub(1);
|
||||
}
|
||||
None => {
|
||||
d.remove_item(f)?;
|
||||
}
|
||||
}
|
||||
d.remove_item(f)?;
|
||||
d.delete_pages(&[pg])?;
|
||||
added = added.saturating_sub(1);
|
||||
} else {
|
||||
d.remove_item(f)?;
|
||||
}
|
||||
@@ -568,6 +595,141 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A facing-pages document with two columns, its inside margin wider than the outside one, so
|
||||
/// left and right pages have their columns in different places. A graphic frame sits on the
|
||||
/// page `marker` (0-based). Returns the session and the marker's id.
|
||||
fn facing_document(pages: usize, marker: usize) -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36});
|
||||
s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins}))
|
||||
.unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let (si, _) = d.page_loc(marker).unwrap();
|
||||
let m = d.page(marker).unwrap().margin_rect();
|
||||
let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0];
|
||||
let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
// Selected, an empty frame would take the placed text.
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// Every frame of the story on the column of its page, the story running through the pages in
|
||||
/// order, two frames a page.
|
||||
fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) {
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let page = d.page_of_item(*f).unwrap();
|
||||
assert_eq!(page, i / 2, "frame {i}");
|
||||
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_fills_the_pages_that_follow_before_adding_any() {
|
||||
// Gap 26: autoflow inserted pages after the first frame's page, in front of the empty
|
||||
// pages the document already had. InDesign threads frames on those first.
|
||||
let dir = std::env::temp_dir().join(format!("dc-place-following-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("long.txt");
|
||||
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 24].join("\n")).unwrap();
|
||||
let (mut s, marker) = facing_document(6, 2);
|
||||
let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
|
||||
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
|
||||
assert_eq!(r["overset"], false, "{r}");
|
||||
assert_eq!(r["pagesAdded"], 0, "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
|
||||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||||
let frames = d.story(sid).unwrap().frames.clone();
|
||||
// Past page 3, which holds the marker: frames go on a page whatever else it holds.
|
||||
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len());
|
||||
assert_frames_in_columns(d, &frames);
|
||||
// The text ends before the document does: no empty frame after it.
|
||||
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
|
||||
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())));
|
||||
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5);
|
||||
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_adds_pages_at_the_end_of_the_document() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-place-end-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("long.txt");
|
||||
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 60].join("\n")).unwrap();
|
||||
let (mut s, marker) = facing_document(3, 2);
|
||||
let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
|
||||
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
|
||||
assert_eq!(r["overset"], false, "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.page_count() > 4, "{r}");
|
||||
assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}");
|
||||
// The document's own pages come first, the added ones after the last of them.
|
||||
assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
|
||||
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
|
||||
let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone();
|
||||
assert_frames_in_columns(d, &frames);
|
||||
assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1));
|
||||
// One undo step takes the frames, the pages and the story away.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
|
||||
assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none());
|
||||
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_fills_every_column_of_a_following_page() {
|
||||
// A first frame across the whole live area holds twice what a column does, so autoflow
|
||||
// asks for half the frames it needs. Asked page by page, it left the later columns of a
|
||||
// page out of the thread and went on to the next page.
|
||||
let dir = std::env::temp_dir().join(format!("dc-place-under-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("long.txt");
|
||||
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
|
||||
let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect();
|
||||
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap();
|
||||
assert_eq!(r["overset"], false, "{r}");
|
||||
assert_eq!(r["pagesAdded"], 0, "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
|
||||
assert_eq!(d.item(frames[0]).unwrap().bounds(), m);
|
||||
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len());
|
||||
for (k, f) in frames[1..].iter().enumerate() {
|
||||
let page = d.page_of_item(*f).unwrap();
|
||||
assert_eq!(page, 1 + k / 2, "frame {}", k + 1);
|
||||
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() {
|
||||
// A small first frame makes autoflow ask for more frames than the columns need; the ones
|
||||
// it set on the following pages past the end of the text go again, the pages stay.
|
||||
let dir = std::env::temp_dir().join(format!("dc-place-small-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("long.txt");
|
||||
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 12].join("\n")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap();
|
||||
assert_eq!(r["overset"], false, "{r}");
|
||||
assert_eq!(r["pagesAdded"], 0, "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.page_count(), 8);
|
||||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||||
let frames = &d.story(sid).unwrap().frames;
|
||||
assert!(frames.len() >= 2, "{r}");
|
||||
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
|
||||
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len());
|
||||
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), frames.len());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_adds_only_the_pages_breaks_ask_for() {
|
||||
// Regression (#262): a frame a break ends early made autoflow take it for a frame that
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print…",
|
||||
["File"],
|
||||
Some("Cmd+P"),
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing), created?: int (Unix seconds, as file.exportPdf)} → {printer, copies, pages, command, bytes}",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
@@ -95,7 +95,8 @@ mod tests {
|
||||
fn print_submits_the_selected_export_and_reports_refusal() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
|
||||
// A fixed creation date: both exports would otherwise take the clock's second.
|
||||
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true, "created": 1_700_000_000});
|
||||
let expected = {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let options = super::super::export::options(¶ms, d.page_count()).unwrap();
|
||||
@@ -118,5 +119,6 @@ mod tests {
|
||||
assert!(refusal.to_string().contains("printer offline"));
|
||||
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
|
||||
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
|
||||
assert!(print_with(&mut s, &json!({"created": "today"}), |_, _| panic!("an invalid date must not spool")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1502,12 +1502,29 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// A character fill must name a swatch of the document.
|
||||
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
|
||||
validate_char_size(attrs, command_id)?;
|
||||
match &attrs.fill {
|
||||
Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's
|
||||
/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative
|
||||
/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page.
|
||||
pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> {
|
||||
match attrs.size {
|
||||
Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => {
|
||||
Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt")))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type size limits (InDesign's, and the ones `type.ruby` already uses).
|
||||
pub(crate) const MIN_TYPE_SIZE: f64 = 0.1;
|
||||
pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0;
|
||||
|
||||
/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that
|
||||
/// line, and so the one a line-affecting attribute like leading can land on right away instead of
|
||||
/// waiting for the next typed character (otherwise an empty line could never get its own leading).
|
||||
@@ -3911,3 +3928,42 @@ mod pick_up_format_tests {
|
||||
let _ = ItemId(rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod size_bounds_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative
|
||||
/// size was accepted the same way, and 1e9 pt asked the composer for a line a million times
|
||||
/// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt.
|
||||
#[test]
|
||||
fn a_type_size_outside_the_range_is_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
|
||||
let select = |s: &mut Session| {
|
||||
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap();
|
||||
};
|
||||
for size in [0.0, -12.0, 0.05, 1297.0, 1e9] {
|
||||
select(&mut s);
|
||||
let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string();
|
||||
assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}");
|
||||
}
|
||||
// The sizes in range still apply, including the ends.
|
||||
for size in [0.1, 12.0, 1296.0] {
|
||||
select(&mut s);
|
||||
s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}"));
|
||||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size));
|
||||
}
|
||||
// And the styles that carry character attributes are checked the same way.
|
||||
for (command, params) in [
|
||||
("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})),
|
||||
("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})),
|
||||
] {
|
||||
let e = s.execute(command, ¶ms).expect_err("refused").to_string();
|
||||
assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Thread Text Frames",
|
||||
[],
|
||||
None,
|
||||
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
|
||||
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then thread a frame per column on the pages that follow, adding pages at the end of the document until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
|
||||
has_doc,
|
||||
thread
|
||||
),
|
||||
|
||||
@@ -2797,7 +2797,25 @@ impl<'a> Ex<'a> {
|
||||
ce.set(&format!("{side}StrokePriority"), priority);
|
||||
}
|
||||
}
|
||||
for psr in self.story_paras(&cell.text) {
|
||||
let mut paras = self.story_paras(&cell.text);
|
||||
// A graphic cell holds its image as an inline object in the cell's first paragraph.
|
||||
if let Some(g) = &cell.graphic
|
||||
&& let Some(object) = self.cell_graphic_el(g)
|
||||
{
|
||||
// The cell clips its graphic on screen and in PDF; IDML does so only when asked.
|
||||
ce.set("ClipContentToCell", bool_s(true));
|
||||
let mut range = self.csr_el(&CharFormat::default());
|
||||
range.push(object);
|
||||
match paras.first_mut() {
|
||||
Some(first) => first.push(range),
|
||||
None => {
|
||||
let mut psr = self.psr_el(&ParaFormat::default());
|
||||
psr.push(range);
|
||||
paras.push(psr);
|
||||
}
|
||||
}
|
||||
}
|
||||
for psr in paras {
|
||||
ce.push(psr);
|
||||
}
|
||||
el.push(ce);
|
||||
@@ -2806,6 +2824,20 @@ impl<'a> Ex<'a> {
|
||||
el
|
||||
}
|
||||
|
||||
/// The frame of a table cell's graphic: an inline `Rectangle` around the shown image, or `None`
|
||||
/// when the graphic's asset is missing.
|
||||
fn cell_graphic_el(&mut self, g: &designcraft_doc::Graphic) -> Option<El> {
|
||||
self.d.assets.get(&g.asset)?;
|
||||
let shown = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
|
||||
let layer = self.d.layers.first().map(|l| l.id).unwrap_or(designcraft_doc::ids::LayerId(0));
|
||||
let mut item = Item::new(designcraft_doc::ids::ItemId(0), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(shown));
|
||||
item.content = Content::Graphic(g.clone());
|
||||
let mut el = self.item_el(&item, Affine::IDENTITY);
|
||||
el.set("Self", self.fresh());
|
||||
el.push(El::new("AnchoredObjectSetting").attr("AnchoredPosition", "InlinePosition").attr("AnchorYoffset", "0"));
|
||||
Some(el)
|
||||
}
|
||||
|
||||
fn psr_el(&mut self, pf: &ParaFormat) -> El {
|
||||
let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style));
|
||||
let mut props = Vec::new();
|
||||
|
||||
@@ -4368,3 +4368,37 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_
|
||||
let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap();
|
||||
assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight));
|
||||
}
|
||||
|
||||
/// A graphic placed in a table cell is written into the cell, not dropped.
|
||||
#[test]
|
||||
fn exports_graphic_in_table_cell() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Intro", ParaFormat::default()).unwrap();
|
||||
let asset = designcraft_doc::AssetId(d.alloc());
|
||||
let id = d.add_asset(designcraft_doc::Asset {
|
||||
id: asset,
|
||||
name: "tick.png".into(),
|
||||
mime: "image/png".into(),
|
||||
data: std::sync::Arc::new(vec![1, 2, 3]),
|
||||
..Default::default()
|
||||
});
|
||||
let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0);
|
||||
t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic {
|
||||
asset: id,
|
||||
size: (24.0, 24.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(),
|
||||
});
|
||||
d.story_mut(sid).unwrap().insert_table(5, t);
|
||||
let story = zip_text(&export_idml(&d), "Stories/");
|
||||
let cell = story.split("<Cell ").nth(3).unwrap_or_default();
|
||||
let cell = cell.split("</Cell>").next().unwrap_or_default();
|
||||
assert!(cell.contains("<Image "), "{story}");
|
||||
assert!(cell.contains("ClipContentToCell=\"true\""), "{story}");
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::fmt::Write as _;
|
||||
use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions};
|
||||
|
||||
use crate::export::Sheet;
|
||||
use crate::transitions::{int_after, object, rfind};
|
||||
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);
|
||||
@@ -94,7 +94,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -
|
||||
let pages_id = int_after(&cat, "/Pages")?;
|
||||
let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string();
|
||||
let kids_str = &pages[pages.find("/Kids")? + 5..];
|
||||
let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?];
|
||||
let kids_str = bracketed(kids_str)?;
|
||||
let nums: Vec<usize> = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
|
||||
let kids: Vec<usize> = nums.chunks(2).map(|c| c[0]).collect();
|
||||
|
||||
|
||||
+13
-1
@@ -94,7 +94,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option<i64>) -> Option<Vec<u8>>
|
||||
let s0 = find(body, b"stream", 0)? + b"stream".len();
|
||||
let s0 = s0 + body[s0..].iter().take_while(|c| **c == b'\r' || **c == b'\n').count();
|
||||
let s1 = rfind(body, b"endstream")?;
|
||||
let xml = String::from_utf8_lossy(&body[s0..s1]).trim_end().to_string();
|
||||
let xml = String::from_utf8_lossy(body.get(s0..s1)?).trim_end().to_string();
|
||||
let dict = String::from_utf8_lossy(&body[..find(body, b"stream", 0)?]).to_string();
|
||||
if dict.contains("/Filter") {
|
||||
return None;
|
||||
@@ -668,6 +668,18 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// A metadata object whose only `stream` is the one inside `endstream` is refused, not a slice panic.
|
||||
#[test]
|
||||
fn metadata_with_only_endstream_does_not_panic() {
|
||||
let objects = ["<</Type/Catalog/Pages 2 0 R/Metadata 3 0 R>>", "<</Type/Pages/Kids[]/Count 0>>", "<</Type/Metadata>>endstream"];
|
||||
let mut out = b"%PDF-1.6\n".to_vec();
|
||||
for (i, o) in objects.iter().enumerate() {
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"trailer\n<</Size 4/Root 1 0 R>>\nstartxref\n0\n%%EOF\n");
|
||||
assert!(super::make_pdfx4(&out, "t", None).is_none());
|
||||
}
|
||||
|
||||
/// DeviceRGB is allowed when a `DefaultRGB` colour space makes it colour-managed.
|
||||
#[test]
|
||||
fn default_rgb_makes_device_rgb_managed() {
|
||||
|
||||
@@ -43,6 +43,14 @@ pub(crate) fn int_after(s: &str, key: &str) -> Option<usize> {
|
||||
rest[..end].parse().ok()
|
||||
}
|
||||
|
||||
/// The text between the first `[` and the first `]` after it. `None` when there is no such pair (a `]`
|
||||
/// before the `[` included).
|
||||
pub(crate) fn bracketed(s: &str) -> Option<&str> {
|
||||
let open = s.find('[')?;
|
||||
let close = s[open..].find(']')? + open;
|
||||
s.get(open + 1..close)
|
||||
}
|
||||
|
||||
/// Byte range of object `id`'s body (between `obj` and `endobj`).
|
||||
pub(crate) fn object(pdf: &[u8], id: usize) -> Option<std::ops::Range<usize>> {
|
||||
// The latest copy (incremental updates re-state objects further on).
|
||||
@@ -132,7 +140,7 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option<String>]) -> Option<Vec<u8>>
|
||||
let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string();
|
||||
let kids_at = pages.find("/Kids")?;
|
||||
let kids_str = &pages[kids_at + 5..];
|
||||
let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?];
|
||||
let kids_str = bracketed(kids_str)?;
|
||||
let nums: Vec<usize> = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
|
||||
let kids: Vec<usize> = nums.chunks(2).map(|c| c[0]).collect();
|
||||
let starts = object_starts(pdf, extra.iter().zip(&kids).filter_map(|(t, &kid)| t.as_ref().map(|_| kid)));
|
||||
@@ -199,6 +207,31 @@ pub fn add_catalog_entries(pdf: &[u8], entries: &str) -> Option<Vec<u8>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bracket_tests {
|
||||
use super::{add_page_entries, bracketed};
|
||||
|
||||
#[test]
|
||||
fn bracketed_ignores_a_close_before_the_open() {
|
||||
assert_eq!(bracketed(" [3 0 R 4 0 R] "), Some("3 0 R 4 0 R"));
|
||||
assert_eq!(bracketed("] ["), None);
|
||||
assert_eq!(bracketed("] [3 0 R]"), Some("3 0 R"));
|
||||
assert_eq!(bracketed("[]"), Some(""));
|
||||
}
|
||||
|
||||
/// `]` before the `[` of /Kids (a spoofed page tree) is refused, not a slice panic.
|
||||
#[test]
|
||||
fn kids_closed_before_opened_is_refused() {
|
||||
let objects = ["<</Type/Catalog/Pages 2 0 R>>", "<</Type/Pages/Kids ] [3 0 R/Count 1>>"];
|
||||
let mut out = b"%PDF-1.6\n".to_vec();
|
||||
for (i, o) in objects.iter().enumerate() {
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"trailer\n<</Size 3/Root 1 0 R>>\nstartxref\n0\n%%EOF\n");
|
||||
assert!(add_page_entries(&out, &[Some("/Rotate 0".to_string())]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod object_index_tests {
|
||||
use super::{MAX_INDEXED_PAGE_OBJECTS, object_starts};
|
||||
|
||||
@@ -114,7 +114,7 @@ pub fn import(name: &str, bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
if l.ends_with(".doc") {
|
||||
return Err(ImportError::Unsupported("legacy Word .doc (save it as .docx)".into()));
|
||||
}
|
||||
Ok(plain(&String::from_utf8_lossy(bytes)))
|
||||
Ok(plain(&tagged::decode(bytes)))
|
||||
}
|
||||
|
||||
/// Plain text: line breaks become paragraphs (CRLF / CR normalised).
|
||||
@@ -136,4 +136,14 @@ mod tests {
|
||||
assert!(is_text_file("Report.DOCX") && !is_text_file("a.png"));
|
||||
assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_utf16() {
|
||||
let mut le = vec![0xFF, 0xFE];
|
||||
le.extend("Hi there".encode_utf16().flat_map(u16::to_le_bytes));
|
||||
assert_eq!(import("a.txt", &le).unwrap().story.text, "Hi there");
|
||||
let mut be = vec![0xFE, 0xFF];
|
||||
be.extend("Hi there".encode_utf16().flat_map(u16::to_be_bytes));
|
||||
assert_eq!(import("a.txt", &be).unwrap().story.text, "Hi there");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
stack.push(top);
|
||||
}
|
||||
first = false;
|
||||
// A group is not a character: the `\uN` fallback skip ends here too.
|
||||
pending = 0;
|
||||
// `{\*\dest …}`: an ignorable destination.
|
||||
if chars.get(i + 1) == Some(&'\\')
|
||||
&& chars.get(i + 2) == Some(&'*')
|
||||
@@ -134,6 +136,11 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
}
|
||||
if !next.is_ascii_alphabetic() {
|
||||
// Control symbols: \\ \{ \} \~ (nbsp) \- (soft hyphen) \_ (nb hyphen).
|
||||
i += 2;
|
||||
if pending > 0 {
|
||||
pending -= 1;
|
||||
continue;
|
||||
}
|
||||
let skip = stack.last().is_some_and(|s| s.skip);
|
||||
if !skip {
|
||||
match next {
|
||||
@@ -149,7 +156,6 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// A control word with an optional numeric parameter and one delimiting space.
|
||||
@@ -172,6 +178,11 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
k += 1;
|
||||
}
|
||||
i = k;
|
||||
// A control word is one character of the `\uN` fallback (a following `\u` is not).
|
||||
if pending > 0 && word != "u" {
|
||||
pending -= 1;
|
||||
continue;
|
||||
}
|
||||
let skip = stack.last().is_some_and(|s| s.skip);
|
||||
if SKIP.contains(&word.as_str()) {
|
||||
flush(&mut st, &mut buf, &fmt);
|
||||
@@ -234,9 +245,12 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
}
|
||||
"u" => {
|
||||
if let Some(n) = param {
|
||||
let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 };
|
||||
if let Some(ch) = units.push(unit) {
|
||||
buf.push(ch);
|
||||
// RTF allows -32768..=65535; anything else is dropped.
|
||||
if (-32768..=65535).contains(&n) {
|
||||
let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 };
|
||||
if let Some(ch) = units.push(unit) {
|
||||
buf.push(ch);
|
||||
}
|
||||
}
|
||||
pending = stack.last().map_or(1, |s| s.uc);
|
||||
}
|
||||
@@ -282,6 +296,12 @@ fn cp1252(b: u8) -> char {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn out_of_range_unicode_escape_is_dropped() {
|
||||
assert_eq!(import(br"{\rtf1 a\u4294967361?b}").unwrap().story.text, "ab");
|
||||
assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_rtf_text_and_formatting() {
|
||||
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;}
|
||||
@@ -320,6 +340,18 @@ Caf\'e9 \u8364? price\tab tab\line next\par
|
||||
assert_eq!(i.story.text, "Rock\u{2014}-roll, x\t-y, ab");
|
||||
}
|
||||
|
||||
/// A control word or control symbol is one character of the `\uN` fallback, so it is skipped
|
||||
/// like a plain character and does not eat the text after it.
|
||||
#[test]
|
||||
fn control_fallback_after_unicode_is_skipped() {
|
||||
let i = import(br"{\rtf1 \u8220\ldblquote Hi\u8221\rdblquote x\u8212\emdash y}").unwrap();
|
||||
assert_eq!(i.story.text, "\u{201C}Hi\u{201D} x\u{2014}y");
|
||||
let i = import(br"{\rtf1 \u8220\{Hi}").unwrap();
|
||||
assert_eq!(i.story.text, "\u{201C}Hi");
|
||||
let i = import(br"{\rtf1 \u8220{\b Hi}}").unwrap();
|
||||
assert_eq!(i.story.text, "\u{201C}Hi");
|
||||
}
|
||||
|
||||
/// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form
|
||||
/// our own exporter and Word write). Each half was converted alone and dropped.
|
||||
#[test]
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
|
||||
}
|
||||
|
||||
/// Text of a Tagged Text file (ASCII, UTF-8 or UTF-16 by byte-order mark).
|
||||
fn decode(bytes: &[u8]) -> String {
|
||||
pub(crate) fn decode(bytes: &[u8]) -> String {
|
||||
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
|
||||
let le = bytes[0] == 0xFF;
|
||||
let units: Vec<u16> = bytes[2..]
|
||||
|
||||
@@ -77,16 +77,24 @@ pub fn paint_chat_icon(p: &egui::Painter, c: egui::Pos2, s: f32, color: Color32)
|
||||
p.circle_filled(body.center() + vec2(s * 0.18, 0.0), eye, bg);
|
||||
}
|
||||
|
||||
/// The big "Join our Discord" button. Returns true when clicked.
|
||||
/// The Discord button's size for `label`: `min`, widened when the label would not fit inside it.
|
||||
pub fn discord_button_size(ctx: &egui::Context, label: &str, min: egui::Vec2) -> egui::Vec2 {
|
||||
let galley = crate::rtl::plain(ctx, label, semibold((min.y * 0.42).max(11.0)), Color32::WHITE);
|
||||
egui::vec2(min.x.max(min.y * 0.5 + 8.0 + galley.size().x + min.y), min.y)
|
||||
}
|
||||
|
||||
/// The big "Join our Discord" button. Returns true when clicked. `size` is the minimum: a longer label
|
||||
/// widens the button (see `discord_button_size`) rather than spilling out of it.
|
||||
pub fn discord_button(ui: &mut egui::Ui, label: &str, size: egui::Vec2) -> bool {
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD };
|
||||
ui.painter().rect_filled(r, size.y / 2.0, fill);
|
||||
let size = discord_button_size(ui.ctx(), label, size);
|
||||
let icon = size.y * 0.5;
|
||||
let font = semibold((size.y * 0.42).max(11.0));
|
||||
let galley = crate::rtl::plain(ui.ctx(), label, font, Color32::WHITE);
|
||||
let total = icon + 8.0 + galley.size().x;
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD };
|
||||
ui.painter().rect_filled(r, size.y / 2.0, fill);
|
||||
let x0 = r.center().x - total / 2.0;
|
||||
paint_chat_icon(ui.painter(), pos2(x0 + icon / 2.0, r.center().y), icon, Color32::WHITE);
|
||||
ui.painter().galley(pos2(x0 + icon + 8.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
|
||||
|
||||
@@ -5317,7 +5317,7 @@ mod tests {
|
||||
let (pen, direct, text) = (shown("pen"), shown("directSelection"), shown("type"));
|
||||
let ppp = h.ctx.pixels_per_point();
|
||||
let images = &mut h.state_mut().cursors;
|
||||
match crate::cursors::MODE {
|
||||
match crate::cursors::mode() {
|
||||
crate::cursors::Mode::Os => {
|
||||
assert_eq!(pen.1, images.get(Cursor::Pen, ppp), "Pen");
|
||||
assert_eq!(direct.1, images.get(Cursor::ArrowHollow, ppp), "Direct Selection");
|
||||
|
||||
@@ -1060,7 +1060,9 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::hover());
|
||||
crate::about::paint_mark(ui, r, crate::about::BRAND);
|
||||
ui.add_space(8.0);
|
||||
let text_width = (ui.available_width() - BUTTON.x - 16.0).max(120.0);
|
||||
let label = crate::i18n::tr(&app.ui.language, "Join our Discord");
|
||||
let button = crate::about::discord_button_size(ui.ctx(), label, BUTTON);
|
||||
let text_width = (ui.available_width() - button.x - 16.0).max(120.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(text_width);
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
@@ -1090,7 +1092,7 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::about::discord_button(ui, crate::i18n::tr(&app.ui.language, "Join our Discord"), BUTTON) {
|
||||
if crate::about::discord_button(ui, label, BUTTON) {
|
||||
let _ = app.run("help.discord", json!({}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
//! what a click does (add, delete, close, convert, continue), and a curved double arrow for rotating.
|
||||
//! Cursors without a glyph stay system cursors.
|
||||
//!
|
||||
//! Linux and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself, so
|
||||
//! they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show
|
||||
//! twice as big, or blurred), so there the canvas paints the glyph at the pointer. The
|
||||
//! web build keeps the system cursors.
|
||||
//! Linux (X11) and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself,
|
||||
//! so they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show
|
||||
//! twice as big, or blurred) and a Wayland session (native or through XWayland) scales it by its own
|
||||
//! rules, so there the canvas paints the glyph at the pointer. The web build keeps the system cursors.
|
||||
|
||||
use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex};
|
||||
use egui::{Color32, CustomCursorImage, Pos2, Shape, Stroke, Vec2, pos2, vec2};
|
||||
@@ -27,13 +27,32 @@ pub enum Mode {
|
||||
System,
|
||||
}
|
||||
|
||||
pub const MODE: Mode = if cfg!(target_arch = "wasm32") {
|
||||
Mode::System
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Mode::Painted
|
||||
} else {
|
||||
Mode::Os
|
||||
};
|
||||
/// The mode for a platform: `wayland` is true on a Linux Wayland session.
|
||||
fn mode_for(wasm: bool, macos: bool, wayland: bool) -> Mode {
|
||||
if wasm {
|
||||
Mode::System
|
||||
} else if macos || wayland {
|
||||
Mode::Painted
|
||||
} else {
|
||||
Mode::Os
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the environment describes a Wayland session: a Wayland display, unless the session says X11.
|
||||
fn is_wayland(wayland_display: Option<&str>, session_type: Option<&str>) -> bool {
|
||||
wayland_display.is_some_and(|d| !d.is_empty()) && !session_type.is_some_and(|t| t.eq_ignore_ascii_case("x11"))
|
||||
}
|
||||
|
||||
/// How cursor glyphs are shown on this system. On Wayland the compositor sizes a cursor bitmap by
|
||||
/// its own scale, so the OS glyph came out far too big; there the canvas paints it, as on macOS.
|
||||
pub fn mode() -> Mode {
|
||||
static MODE: std::sync::OnceLock<Mode> = std::sync::OnceLock::new();
|
||||
*MODE.get_or_init(|| {
|
||||
let wayland = cfg!(target_os = "linux")
|
||||
&& is_wayland(std::env::var("WAYLAND_DISPLAY").ok().as_deref(), std::env::var("XDG_SESSION_TYPE").ok().as_deref());
|
||||
mode_for(cfg!(target_arch = "wasm32"), cfg!(target_os = "macos"), wayland)
|
||||
})
|
||||
}
|
||||
|
||||
/// The shapes of one glyph: every halo goes under all the ink, so one part's halo never cuts
|
||||
/// through another part's ink.
|
||||
@@ -289,9 +308,9 @@ pub fn system(c: Cursor) -> egui::CursorIcon {
|
||||
}
|
||||
|
||||
/// Show cursor `c` with the pointer at `hover` (screen points): set the OS cursor bitmap or paint
|
||||
/// the glyph per [`MODE`], and return the cursor icon to set.
|
||||
/// the glyph per [`mode`], and return the cursor icon to set.
|
||||
pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) -> egui::CursorIcon {
|
||||
match MODE {
|
||||
match mode() {
|
||||
Mode::Os => {
|
||||
// The glyphs keep the system cursors' size whatever the interface scale.
|
||||
let ppp = ctx.pixels_per_point() / ctx.zoom_factor().max(0.01);
|
||||
@@ -359,6 +378,19 @@ mod tests {
|
||||
assert!(rasterize(Cursor::Pen, f32::NAN).is_none() && rasterize(Cursor::Pen, 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_paints_the_glyphs_and_x11_keeps_os_cursors() {
|
||||
assert_eq!(mode_for(false, false, true), Mode::Painted);
|
||||
assert_eq!(mode_for(false, false, false), Mode::Os);
|
||||
assert_eq!(mode_for(false, true, false), Mode::Painted);
|
||||
assert_eq!(mode_for(true, false, true), Mode::System);
|
||||
assert!(is_wayland(Some("wayland-0"), Some("wayland")));
|
||||
assert!(is_wayland(Some("wayland-0"), None));
|
||||
assert!(!is_wayland(Some("wayland-0"), Some("x11")));
|
||||
assert!(!is_wayland(Some(""), Some("wayland")));
|
||||
assert!(!is_wayland(None, Some("wayland")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_arrows_differ_in_fill() {
|
||||
// A pixel inside the arrow's body, below the tip: ink for Selection, halo for Direct.
|
||||
|
||||
+118
-125
@@ -4449,6 +4449,12 @@ fn column(lang: &str) -> Option<usize> {
|
||||
|
||||
/// `s` in `lang` (English, or the string itself, when there's no translation).
|
||||
pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
|
||||
let text = lookup(lang, s);
|
||||
// An empty row is a translation nobody has written yet.
|
||||
if text.trim().is_empty() { s } else { text }
|
||||
}
|
||||
|
||||
fn lookup<'a>(lang: &str, s: &'a str) -> &'a str {
|
||||
static GERMAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
static JAPANESE: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
static TRANSLATIONS: OnceLock<HashMap<&'static str, [&'static str; 5]>> = OnceLock::new();
|
||||
@@ -4467,18 +4473,18 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
|
||||
// The German and Japanese tables cover the whole interface; the shared table's German and Japanese
|
||||
// columns are the fallback for keys added there after them.
|
||||
if lang == "de"
|
||||
&& let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied()
|
||||
&& let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty())
|
||||
{
|
||||
return text;
|
||||
}
|
||||
if lang == "ja"
|
||||
&& let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied()
|
||||
&& let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty())
|
||||
{
|
||||
return text;
|
||||
}
|
||||
// `fr::TABLE` holds the strings the shared table has no row for.
|
||||
if lang == "fr"
|
||||
&& let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied()
|
||||
&& let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty())
|
||||
{
|
||||
return text;
|
||||
}
|
||||
@@ -4524,11 +4530,13 @@ pub fn tr_context<'a>(lang: &str, s: &'a str, context: &str) -> &'a str {
|
||||
"ar" => ar::CONTEXT,
|
||||
_ => &[],
|
||||
};
|
||||
if let Some((_, _, text)) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context) {
|
||||
let written = |text: &&str| !text.trim().is_empty();
|
||||
if let Some(text) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context).map(|(_, _, text)| *text).filter(written) {
|
||||
return text;
|
||||
}
|
||||
if let Some(c) = column(lang)
|
||||
&& let Some(text) = CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c))
|
||||
&& let Some(text) =
|
||||
CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c)).copied().filter(written)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
@@ -4567,7 +4575,9 @@ pub fn count_label(lang: &str, noun: &str, count: usize) -> String {
|
||||
2..=4 => few,
|
||||
_ => other,
|
||||
};
|
||||
return format!("{count} {form}");
|
||||
if !form.trim().is_empty() {
|
||||
return format!("{count} {form}");
|
||||
}
|
||||
}
|
||||
format!("{count} {}", tr(lang, noun))
|
||||
}
|
||||
@@ -4625,15 +4635,9 @@ mod tests {
|
||||
/// The German and Japanese tables translate the whole interface and win over the shared columns.
|
||||
const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"];
|
||||
|
||||
#[test]
|
||||
fn every_interface_language_has_a_catalog() {
|
||||
for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
|
||||
assert_eq!(column(lang), Some(i), "{lang}");
|
||||
}
|
||||
for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) {
|
||||
assert!(!name.is_empty(), "{code}");
|
||||
assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES");
|
||||
}
|
||||
/// What `tr` shows for a row: an empty one is still untranslated.
|
||||
fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str {
|
||||
if text.trim().is_empty() { english } else { text }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4644,13 +4648,16 @@ mod tests {
|
||||
for (key, translations) in TABLE {
|
||||
for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) {
|
||||
// A language's own table is looked up before the shared column.
|
||||
assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}");
|
||||
let expected = own(lang, key).filter(|text| !text.trim().is_empty()).unwrap_or(*shared);
|
||||
assert_eq!(tr(lang, key), or_english(expected, key), "{lang}: {key}");
|
||||
}
|
||||
assert_eq!(tr("", key), *key);
|
||||
}
|
||||
for (lang, table) in LANGUAGE_TABLES {
|
||||
for (key, expected) in *table {
|
||||
assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
|
||||
if !expected.trim().is_empty() {
|
||||
assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
|
||||
}
|
||||
assert_eq!(tr("", key), *key);
|
||||
}
|
||||
}
|
||||
@@ -4667,7 +4674,9 @@ mod tests {
|
||||
fn context_captions_come_from_their_context_or_the_plain_translation() {
|
||||
for (lang, table) in CONTEXT_TABLES {
|
||||
for (key, scope, text) in *table {
|
||||
assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})");
|
||||
if !text.trim().is_empty() {
|
||||
assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (key, scope, row) in CONTEXT {
|
||||
@@ -4678,8 +4687,10 @@ mod tests {
|
||||
.filter(|(code, _)| *code == lang)
|
||||
.flat_map(|(_, table)| table.iter())
|
||||
.find(|(k, s, _)| k == key && s == scope)
|
||||
.map(|(_, _, text)| *text);
|
||||
assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})");
|
||||
.map(|(_, _, text)| *text)
|
||||
.filter(|text| !text.trim().is_empty());
|
||||
let expected = own.or(Some(*shared).filter(|text| !text.trim().is_empty())).unwrap_or_else(|| tr(lang, key));
|
||||
assert_eq!(tr_context(lang, key, scope), expected, "{lang}: {key} ({scope})");
|
||||
}
|
||||
}
|
||||
// An unknown context, or a language without one, keeps the plain translation.
|
||||
@@ -4692,33 +4703,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding,
|
||||
/// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back
|
||||
/// to English, so missing translations don't fail here.
|
||||
#[test]
|
||||
fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() {
|
||||
// (language, key, translation) of every catalog, and every catalog's keys by name.
|
||||
let mut entries: Vec<(&str, &str, &str)> = Vec::new();
|
||||
let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new();
|
||||
for (lang, table) in LANGUAGE_TABLES {
|
||||
let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect();
|
||||
assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys");
|
||||
catalogs.push((*lang, keys));
|
||||
entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text)));
|
||||
}
|
||||
let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
|
||||
assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
|
||||
catalogs.push(("shared", shared.clone()));
|
||||
/// The tables `tr` reads, as (language, key, translation), context captions included.
|
||||
fn entries() -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect();
|
||||
for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) {
|
||||
entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text))));
|
||||
entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text))));
|
||||
}
|
||||
let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::<Vec<_>>())];
|
||||
for (lang, table) in CONTEXT_TABLES {
|
||||
contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect()));
|
||||
entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text)));
|
||||
}
|
||||
entries.extend(CONTEXT_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, _, text)| (*lang, *key, *text))));
|
||||
entries
|
||||
}
|
||||
|
||||
/// Every catalog has one row per key and keeps the English placeholders. A string a catalog
|
||||
/// doesn't translate shows in English, so a missing or empty row fails nothing here; the
|
||||
/// `missing_translations_report` lists them.
|
||||
#[test]
|
||||
fn every_catalog_has_unique_keys_and_keeps_placeholders() {
|
||||
for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
|
||||
assert_eq!(column(lang), Some(i), "{lang}");
|
||||
}
|
||||
for (lang, table) in LANGUAGE_TABLES {
|
||||
let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect();
|
||||
assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys");
|
||||
}
|
||||
let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
|
||||
assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
|
||||
let shared_contexts: HashSet<_> = CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect();
|
||||
assert_eq!(shared_contexts.len(), CONTEXT.len(), "duplicate keys in the shared context table");
|
||||
for (lang, table) in CONTEXT_TABLES {
|
||||
let keys: HashSet<_> = table.iter().map(|(key, scope, _)| (*key, *scope)).collect();
|
||||
assert_eq!(keys.len(), table.len(), "{lang}: duplicate context keys");
|
||||
}
|
||||
// A per-language table is looked up before the shared table, so a key in both hides the
|
||||
// shared column, except in the tables that translate the whole interface.
|
||||
for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) {
|
||||
@@ -4726,41 +4741,58 @@ mod tests {
|
||||
assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}");
|
||||
}
|
||||
|
||||
// A key is current while the interface or another catalog has it.
|
||||
let interface = interface_strings();
|
||||
let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key));
|
||||
for (name, keys) in &catalogs {
|
||||
let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect();
|
||||
stale.sort();
|
||||
assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}");
|
||||
}
|
||||
for (name, keys) in &contexts {
|
||||
let unique: HashSet<_> = keys.iter().collect();
|
||||
assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys");
|
||||
let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect();
|
||||
assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}");
|
||||
}
|
||||
|
||||
let placeholders = |s: &'static str| {
|
||||
let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect();
|
||||
names.sort_unstable();
|
||||
names
|
||||
};
|
||||
let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("...");
|
||||
let colon = |s: &str| s.ends_with(':') || s.ends_with(':');
|
||||
// Full-width punctuation carries its own spacing.
|
||||
let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c);
|
||||
for (lang, key, text) in entries {
|
||||
assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}");
|
||||
for (lang, key, text) in entries().into_iter().filter(|(_, _, text)| !text.trim().is_empty()) {
|
||||
assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}");
|
||||
// Dialog ellipses, label colons and padded captions stay as in English.
|
||||
assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}");
|
||||
assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}");
|
||||
if !text.starts_with(full_width) {
|
||||
assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}");
|
||||
}
|
||||
if !text.ends_with(full_width) {
|
||||
assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The strings each interface language shows in English, for the `cargo xtask ci` log. It
|
||||
/// always passes: `cargo xtask i18n-report` runs it.
|
||||
#[test]
|
||||
#[ignore = "a report, run by `cargo xtask i18n-report`"]
|
||||
fn missing_translations_report() {
|
||||
let interface = interface_strings();
|
||||
let catalogs: Vec<(&str, HashSet<&str>)> = LANGUAGE_TABLES
|
||||
.iter()
|
||||
.map(|(lang, table)| (*lang, table.iter().map(|(key, _)| *key).collect()))
|
||||
.chain([("shared", TABLE.iter().map(|(key, _)| *key).collect())])
|
||||
.collect();
|
||||
let mut keys: Vec<&str> = interface.iter().copied().chain(catalogs.iter().flat_map(|(_, keys)| keys.iter().copied())).collect();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
// `tr` hands back the string it was given when it has no translation.
|
||||
let missing = |lang: &str| -> Vec<&str> {
|
||||
keys.iter()
|
||||
.copied()
|
||||
.filter(|key| {
|
||||
let english = (*key).to_owned();
|
||||
std::ptr::eq(tr(lang, &english), english.as_str())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let report: Vec<(&str, Vec<&str>)> = LANGUAGES.iter().filter(|(code, _)| !code.is_empty()).map(|(code, _)| (*code, missing(code))).collect();
|
||||
println!("i18n report: {} interface strings; a missing translation shows in English.", keys.len());
|
||||
for (lang, missing) in &report {
|
||||
println!(" {lang:<6} {:>5} missing", missing.len());
|
||||
}
|
||||
for (lang, missing) in report.iter().filter(|(_, missing)| !missing.is_empty()) {
|
||||
println!("{lang} ({}): {}", missing.len(), missing.iter().map(|key| format!("{key:?}")).collect::<Vec<_>>().join(", "));
|
||||
}
|
||||
// A catalog key neither the interface list nor another catalog knows is stale, or a label
|
||||
// `interface_strings` doesn't list yet.
|
||||
for (name, own) in &catalogs {
|
||||
let mut unknown: Vec<_> = own
|
||||
.iter()
|
||||
.filter(|key| !interface.contains(*key) && !catalogs.iter().any(|(other, keys)| other != name && keys.contains(*key)))
|
||||
.collect();
|
||||
unknown.sort_unstable();
|
||||
if !unknown.is_empty() {
|
||||
println!("{name}: {} rows for keys the interface list doesn't have: {unknown:?}", unknown.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4788,47 +4820,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Languages with several plural forms put the category first, so every number reads the same.
|
||||
#[test]
|
||||
fn georgian_counts_use_category_captions_for_every_number() {
|
||||
for n in [1, 2, 5, 21] {
|
||||
assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_counts_use_category_captions_for_every_number() {
|
||||
for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] {
|
||||
assert_eq!(count_label("uk", "pages", n), format!("сторінок: {n}"));
|
||||
assert_eq!(count_label("uk", "objects", n), format!("об’єктів: {n}"));
|
||||
assert_eq!(count_label("uk", "errors", n), format!("помилок: {n}"));
|
||||
let pages = tr("uk", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string());
|
||||
assert_eq!(pages, format!("Сторінок: {n}; розворотів: {n}"));
|
||||
fn category_first_counts_put_the_category_before_every_number() {
|
||||
assert!(!category_first("de") && !category_first(""));
|
||||
for lang in ["ka", "uk", "ru", "pl"] {
|
||||
assert!(category_first(lang), "{lang}");
|
||||
for noun in ["pages", "objects", "errors"] {
|
||||
for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] {
|
||||
assert_eq!(count_label(lang, noun, n), format!("{}: {n}", tr(lang, noun)), "{lang}: {noun}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(count_label("", "objects", 2), "2 objects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_counts_use_category_captions_for_every_number() {
|
||||
assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first(""));
|
||||
for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] {
|
||||
assert_eq!(count_label("ru", "pages", n), format!("страниц: {n}"));
|
||||
assert_eq!(count_label("ru", "objects", n), format!("объектов: {n}"));
|
||||
assert_eq!(count_label("ru", "errors", n), format!("ошибок: {n}"));
|
||||
let pages = tr("ru", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string());
|
||||
assert_eq!(pages, format!("Страниц: {n}; разворотов: {n}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_counts_use_category_captions_for_every_number() {
|
||||
for n in [0, 1, 2, 5, 12, 22, 25] {
|
||||
assert_eq!(count_label("pl", "pages", n), format!("Strony: {n}"));
|
||||
assert_eq!(count_label("pl", "objects", n), format!("Obiekty: {n}"));
|
||||
assert_eq!(count_label("pl", "errors", n), format!("Błędy: {n}"));
|
||||
}
|
||||
assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}");
|
||||
}
|
||||
|
||||
/// Every English string the interface passes to `tr`, as far as the command and tool tables and
|
||||
/// the list of UI-only labels know them.
|
||||
fn interface_strings() -> HashSet<&'static str> {
|
||||
@@ -4929,26 +4935,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn czech_counts_use_the_plural_form_for_the_number() {
|
||||
for (n, form) in [(0, "chyb"), (1, "chyba"), (2, "chyby"), (4, "chyby"), (5, "chyb"), (11, "chyb"), (22, "chyb")] {
|
||||
assert_eq!(count_label("cs", "errors", n), format!("{n} {form}"));
|
||||
for (noun, [one, few, other]) in cs::PLURALS {
|
||||
for (n, form) in [(0, other), (1, one), (2, few), (4, few), (5, other), (11, other), (22, other)] {
|
||||
let expected = if form.trim().is_empty() { format!("{n} {}", tr("cs", noun)) } else { format!("{n} {form}") };
|
||||
assert_eq!(count_label("cs", noun, n), expected, "{noun}");
|
||||
}
|
||||
}
|
||||
// The status bar passes the singular noun for one error.
|
||||
assert_eq!(count_label("cs", "error", 1), "1 chyba");
|
||||
assert_eq!(count_label("cs", "pages", 1), "1 stránka");
|
||||
assert_eq!(count_label("cs", "pages", 3), "3 stránky");
|
||||
assert_eq!(count_label("cs", "pages", 5), "5 stránek");
|
||||
assert_eq!(count_label("cs", "stories", 2), "2 články");
|
||||
assert_eq!(count_label("cs", "items", 1), "1 položka");
|
||||
assert_eq!(count_label("cs", "objects", 7), "7 objektů");
|
||||
assert_eq!(count_label("cs", "warnings", 3), "3 varování");
|
||||
assert_eq!(count_label("cs", "layer", 1), "1 vrstva");
|
||||
assert_eq!(count_label("cs", "layers", 3), "3 vrstvy");
|
||||
assert_eq!(count_label("cs", "layers", 8), "8 vrstev");
|
||||
// A noun without plural forms keeps the plain translation.
|
||||
assert_eq!(count_label("cs", "a new noun", 3), "3 a new noun");
|
||||
for (noun, forms) in cs::PLURALS {
|
||||
assert!(forms.iter().all(|form| !form.is_empty()), "{noun}");
|
||||
assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,55 +2075,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Update Data Source", "Datenquelle aktualisieren"),
|
||||
("Validate from Root Element", "Ab Stammelement prüfen"),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::TABLE;
|
||||
use crate::i18n::tr;
|
||||
|
||||
fn placeholders(text: &str) -> Vec<&str> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find('{') {
|
||||
let Some(len) = rest[start..].find('}') else { break };
|
||||
found.push(&rest[start..start + len + 1]);
|
||||
rest = &rest[start + len + 1..];
|
||||
}
|
||||
found.sort_unstable();
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_unique_and_complete() {
|
||||
let mut seen = HashSet::new();
|
||||
for (en, de) in TABLE {
|
||||
assert!(seen.insert(*en), "duplicate {en}");
|
||||
assert!(!de.is_empty(), "{en}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_keep_placeholders_padding_and_endings() {
|
||||
let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("...");
|
||||
for (en, de) in TABLE {
|
||||
assert_eq!(placeholders(en), placeholders(de), "placeholders of {en}");
|
||||
assert_eq!(en.len() - en.trim_start().len(), de.len() - de.trim_start().len(), "leading space of {en}");
|
||||
assert_eq!(en.len() - en.trim_end().len(), de.len() - de.trim_end().len(), "trailing space of {en}");
|
||||
assert_eq!(ellipsis(en), ellipsis(de), "ellipsis of {en}");
|
||||
// The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`.
|
||||
assert!(!de.contains("...") || de == en, "ASCII ellipsis in {en}");
|
||||
assert_eq!(en.trim_end().ends_with(':'), de.trim_end().ends_with(':'), "colon of {en}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_uses_this_table_and_leaves_unknown_text_alone() {
|
||||
assert_eq!(tr("de", "File"), "Datei");
|
||||
assert_eq!(tr("de", "Cancel"), "Abbrechen");
|
||||
assert_eq!(tr("de", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1);
|
||||
let unknown = String::from("A user-defined untranslated label");
|
||||
assert!(std::ptr::eq(tr("de", &unknown), unknown.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2124,55 +2124,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
|
||||
/// Captions whose Japanese depends on where they appear: (English, context, Japanese).
|
||||
pub(super) const CONTEXT: &[(&str, &str, &str)] = &[("Span Columns", "paragraphLayout", "段抜き"), ("Columns:", "grid", "列:")];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::TABLE;
|
||||
use crate::i18n::tr;
|
||||
|
||||
fn placeholders(text: &str) -> Vec<&str> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find('{') {
|
||||
let Some(len) = rest[start..].find('}') else { break };
|
||||
found.push(&rest[start..start + len + 1]);
|
||||
rest = &rest[start + len + 1..];
|
||||
}
|
||||
found.sort_unstable();
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_unique_and_complete() {
|
||||
let mut seen = HashSet::new();
|
||||
for (en, ja) in TABLE {
|
||||
assert!(seen.insert(*en), "duplicate {en}");
|
||||
assert!(!ja.is_empty(), "{en}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_keep_placeholders_padding_and_endings() {
|
||||
let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("...");
|
||||
for (en, ja) in TABLE {
|
||||
assert_eq!(placeholders(en), placeholders(ja), "placeholders of {en}");
|
||||
assert_eq!(en.len() - en.trim_start().len(), ja.len() - ja.trim_start().len(), "leading space of {en}");
|
||||
assert_eq!(en.len() - en.trim_end().len(), ja.len() - ja.trim_end().len(), "trailing space of {en}");
|
||||
assert_eq!(ellipsis(en), ellipsis(ja), "ellipsis of {en}");
|
||||
// The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`.
|
||||
assert!(!ja.contains("...") || ja == en, "ASCII ellipsis in {en}");
|
||||
assert_eq!(en.trim_end().ends_with(':'), ja.trim_end().ends_with(':'), "colon of {en}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_uses_this_table_and_leaves_unknown_text_alone() {
|
||||
assert_eq!(tr("ja", "File"), "ファイル");
|
||||
assert_eq!(tr("ja", "Cancel"), "キャンセル");
|
||||
assert_eq!(tr("ja", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1);
|
||||
let unknown = String::from("A user-defined untranslated label");
|
||||
assert!(std::ptr::eq(tr("ja", &unknown), unknown.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
+7
-8
@@ -1,6 +1,6 @@
|
||||
# Where DesignCraft falls short of InDesign
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
|
||||
InDesign professional from doing real work. This is the work list: agents pick from the top.
|
||||
@@ -206,13 +206,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
parallel story composition.
|
||||
- **Estimate:** 40–80 h.
|
||||
|
||||
### 26. Autoflow fills the following pages before it adds pages (F)
|
||||
- **Missing:** Shift-click autoflow (and autoflow on Place) always adds new pages after the
|
||||
story's last frame, one margin-sized frame each. InDesign first threads a frame per column on
|
||||
the existing pages that follow, then adds pages at the end of the document.
|
||||
- **Impact:** autoflowing into a document that already has pages inserts new pages in the
|
||||
middle of it instead of using the empty pages.
|
||||
- **Estimate:** 4–8 h.
|
||||
### 26. Autoflow flows into the primary text frame of the pages that follow (F)
|
||||
- **Missing:** autoflow threads a frame per column on the pages that follow, then adds pages at
|
||||
the end of the document, as InDesign does. On a page whose parent has a primary text frame it
|
||||
still sets a new frame instead of flowing into the primary one.
|
||||
- **Estimate:** 2–4 h.
|
||||
|
||||
## Lower
|
||||
|
||||
@@ -249,6 +247,7 @@ Cue. These are excluded from every percentage.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | Gap 26 shrunk: autoflow threads a frame per column on the pages that follow the story's last frame, then adds pages at the end of the document |
|
||||
| 2026-10-16 | minor | Gap 27 closed: the loaded text cursor threads into a text frame inside a group (#1075) |
|
||||
| 2026-10-16 | trivial | Gap 13 shrunk: Greek hyphenates, so five languages do (#1077) |
|
||||
| 2026-10-13 | minor | Gap 16: Parent Options dialog and overrides of base items on a parent spread |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Polish and Georgian interfaces; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are reported, never a failure; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Per-language status of the DesignCraft interface and of document typesetting in each script.
|
||||
Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md),
|
||||
@@ -19,6 +19,10 @@ in the UI crate appear in no catalog (some are test data or proper names), so th
|
||||
somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no
|
||||
help content in any language (Help ▸ opens the project's pages).
|
||||
|
||||
A missing translation never fails a test, a build or a release. `cargo xtask i18n-report` (also
|
||||
a step of `cargo xtask ci`, so the list is in every gate log) prints how many interface strings
|
||||
each language lacks, then the strings.
|
||||
|
||||
InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es,
|
||||
fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew
|
||||
come as Middle East editions with an English or French interface and right-to-left features.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DesignCraft parity with Adobe InDesign
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
|
||||
|
||||
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
|
||||
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
|
||||
@@ -211,7 +211,7 @@ table is the one summed.
|
||||
| Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS |
|
||||
| Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth |
|
||||
| Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place |
|
||||
| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids |
|
||||
| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow sets new frames on following pages instead of flowing into their primary text frame; caret formatting discarded (#289); Notes/Track Changes menus; frame grids |
|
||||
| Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) |
|
||||
| Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth |
|
||||
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer |
|
||||
@@ -299,7 +299,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy; R/G/B, C/M/Y/K and hex fields; a CMYK colour stays CMYK unless R/G/B values are typed); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
|
||||
- Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`.
|
||||
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads one frame per page column and adds pages until the text fits; Remove Styles option (`designcraft-textimport`).
|
||||
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads one frame per page column, on the pages that follow first, then adds pages at the end of the document until the text fits; Remove Styles option (`designcraft-textimport`).
|
||||
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
|
||||
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
|
||||
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
|
||||
@@ -368,7 +368,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF.
|
||||
- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X or PDF/A).
|
||||
- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there.
|
||||
- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF.
|
||||
- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF; IDML export writes them as an inline graphic in the cell.
|
||||
- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.
|
||||
- Flex Layout (InDesign 2026; `flex.add`, `flex.convert`, `flex.remove`, `flex.set`, `flex.reorder`, Window › Flex Layout and the Properties panel's Flex Layout section): a frame lays out its objects in rows or columns by direction, wrap, justification (Flex Start, Center, Flex End, Space Between, Space Around, Space Evenly), the alignment grid, Fixed/Auto width and height, padding and spacing; aligned objects are detected on Add (direction, Auto, Space Between); resizing a container reflows it; dragging an object in one reorders it. Not yet: item sizing (Fixed/Auto/Fill Space per object), object styles with Flex Layout settings, IDML (a container exports as a frame with pasted-in objects), nested selection cues on the canvas.
|
||||
- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`).
|
||||
|
||||
+20
-2
@@ -23,8 +23,9 @@ commands:
|
||||
pdf-deps verify the pinned PDF backend, including Krilla dependencies
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call)
|
||||
test workspace tests (cargo nextest when installed, else cargo test), then doctests
|
||||
ci fmt --check, pdf-deps, clippy -D warnings, test, doctest, assets, layers, wasm
|
||||
(stops at first failure)
|
||||
i18n-report list the interface strings each language shows in English (always passes)
|
||||
ci fmt --check, pdf-deps, clippy -D warnings, test, i18n-report, doctest, assets,
|
||||
layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
@@ -42,6 +43,7 @@ fn main() -> ExitCode {
|
||||
Some("pdf-deps") => pdf_deps::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("test") => cmd_test().and_then(|()| cmd_doctest()),
|
||||
Some("i18n-report") => cmd_i18n_report(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
@@ -177,6 +179,21 @@ fn cmd_test() -> Result<(), String> {
|
||||
run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" ")))
|
||||
}
|
||||
|
||||
/// Runs the ignored `missing_translations_report` test with its output shown. It takes the test
|
||||
/// step's targets, so it reuses that build.
|
||||
fn cmd_i18n_report() -> Result<(), String> {
|
||||
const REPORT: &str = "i18n::tests::missing_translations_report";
|
||||
let mut c = cargo();
|
||||
if nextest_available() {
|
||||
c.args(["nextest", "run", "--cargo-quiet"]).args(TEST_TARGETS);
|
||||
c.args(["--run-ignored", "only", "--no-capture", "--status-level", "fail", "--final-status-level", "fail", "-E"]);
|
||||
c.arg(format!("package(designcraft-ui-egui) & test(={REPORT})"));
|
||||
} else {
|
||||
c.args(["test", "-q"]).args(TEST_TARGETS).args(["--", "--ignored", "--exact", REPORT, "--nocapture"]);
|
||||
}
|
||||
run(c, &format!("missing translations ({REPORT})"))
|
||||
}
|
||||
|
||||
/// nextest does not run doctests.
|
||||
fn cmd_doctest() -> Result<(), String> {
|
||||
let mut c = cargo();
|
||||
@@ -205,6 +222,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
}),
|
||||
),
|
||||
("test", Box::new(cmd_test)),
|
||||
("i18n-report", Box::new(cmd_i18n_report)),
|
||||
("doctest", Box::new(cmd_doctest)),
|
||||
("assets", Box::new(|| assets::run(&root()))),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
|
||||
Reference in New Issue
Block a user