mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-03 17:25:28 +00:00
Merge branch 'main' into codex/hungarian-localization
This commit is contained in:
67 files changed
+2860
-599
No files matched your search
+13
-55
@@ -1,10 +1,7 @@
|
||||
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
|
||||
# wasm) on every pull request and every push to main. Today only path-filtered workflows run on
|
||||
# pull requests, so most PRs land with no test run at all.
|
||||
#
|
||||
# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets.
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -14,58 +11,19 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml, so the font tests see what a release build embeds.
|
||||
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: cargo xtask ci
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUST_BACKTRACE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
uses: storytold/craft-common-actions/actions/install-apt-packages@main
|
||||
with:
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
- name: Install Rust (stable + clippy, rustfmt, wasm32)
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
|
||||
--target wasm32-unknown-unknown
|
||||
rustup default stable
|
||||
|
||||
- uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26
|
||||
with:
|
||||
tool: cargo-nextest@0.9.145
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Quality gate
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
timeout-minutes: 90
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
@@ -2,16 +2,17 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "crates/fonts/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
schedule:
|
||||
- cron: "27 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,19 +4,17 @@
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "3 5 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
+3
-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 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -39,7 +39,7 @@ use designcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
|
||||
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
|
||||
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
|
||||
|
||||
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
|
||||
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
|
||||
|
||||
@@ -231,7 +231,21 @@ impl Fallback {
|
||||
choice.backend.name(),
|
||||
choice.record.failed
|
||||
),
|
||||
None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()),
|
||||
// Not a switch from an unfinished start. `begin` has already returned when
|
||||
// `WGPU_BACKEND` names a backend, so wgpu is not being told which to use here -- an
|
||||
// empty or unrecognised value is no choice and reaches this line like an unset one.
|
||||
// The message used to say the environment had been asked, which reads in a log as
|
||||
// though the user had chosen the backend (#1343). What this start did choose is
|
||||
// either the first candidate or the first one that has not failed before, so it says
|
||||
// which of the two it is rather than claiming the first choice.
|
||||
None if choice.record.failed.is_empty() => {
|
||||
log::info!("graphics: {} (nothing has failed before; set WGPU_BACKEND to choose another)", choice.backend.name())
|
||||
}
|
||||
None => log::info!(
|
||||
"graphics: {} (the first that hasn't failed before, after {:?}; set WGPU_BACKEND to choose another)",
|
||||
choice.backend.name(),
|
||||
choice.record.failed
|
||||
),
|
||||
}
|
||||
let instance = instance_backends(choice.backend, &choice.record.failed);
|
||||
Some(Fallback { backend: choice.backend, switched_from: choice.switched_from, instance, path, record: choice.record })
|
||||
|
||||
@@ -277,12 +277,27 @@ fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path:
|
||||
s.into()
|
||||
}
|
||||
|
||||
/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in
|
||||
/// front (the document's, or the last one used) starts there while that folder still exists;
|
||||
/// otherwise the system picks, as it did before.
|
||||
fn save_dialog_start(suggested: &str) -> (Option<std::path::PathBuf>, String) {
|
||||
let path = std::path::Path::new(suggested);
|
||||
let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string());
|
||||
let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf);
|
||||
(folder, name)
|
||||
}
|
||||
|
||||
/// The save dialog: the suggested name's extension as its filter, and the extension added to a
|
||||
/// name typed without it. Adding it names another file than the one the dialog checked, so
|
||||
/// replacing an existing one asks again.
|
||||
fn pick_save(name: &str) -> Option<String> {
|
||||
fn pick_save(suggested: &str) -> Option<String> {
|
||||
let (folder, name) = save_dialog_start(suggested);
|
||||
let name = name.as_str();
|
||||
let ext = suggested_extension(name);
|
||||
let mut dialog = rfd::FileDialog::new().set_file_name(name);
|
||||
if let Some(folder) = folder {
|
||||
dialog = dialog.set_directory(folder);
|
||||
}
|
||||
if let Some(ext) = ext {
|
||||
dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]);
|
||||
}
|
||||
@@ -606,6 +621,17 @@ mod tests {
|
||||
assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept");
|
||||
assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension");
|
||||
}
|
||||
|
||||
/// #1653: the save dialog opens in the folder the suggestion names, unless it is gone.
|
||||
#[test]
|
||||
fn save_dialog_starts_in_the_suggested_folder_while_it_exists() {
|
||||
let folder = std::env::temp_dir();
|
||||
let suggested = folder.join("Brochure.pdf");
|
||||
assert_eq!(super::save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string()));
|
||||
let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf");
|
||||
assert_eq!(super::save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own");
|
||||
assert_eq!(super::save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
|
||||
+23
-2
@@ -128,8 +128,14 @@ pub fn write(swatches: &[Swatch]) -> Vec<u8> {
|
||||
continue;
|
||||
}
|
||||
let mut b = Vec::new();
|
||||
let units: Vec<u16> = s.name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
b.extend((units.len() as u16).to_be_bytes());
|
||||
// The length field is a u16 that counts the terminating NUL: truncate longer names.
|
||||
let mut units: Vec<u16> = s.name.encode_utf16().take(usize::from(u16::MAX) - 1).collect();
|
||||
// Do not leave half of a surrogate pair at the cut.
|
||||
if units.last().is_some_and(|u| (0xD800..0xDC00).contains(u)) {
|
||||
units.pop();
|
||||
}
|
||||
units.push(0);
|
||||
b.extend(u16::try_from(units.len()).unwrap_or(u16::MAX).to_be_bytes());
|
||||
for u in units {
|
||||
b.extend(u.to_be_bytes());
|
||||
}
|
||||
@@ -233,6 +239,21 @@ mod tests {
|
||||
assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_name_is_truncated_to_fit() {
|
||||
let sw = vec![Swatch {
|
||||
name: "x".repeat(70_000),
|
||||
value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process },
|
||||
locked: false,
|
||||
named: true,
|
||||
hidden: false,
|
||||
}];
|
||||
let back = read(&write(&sw)).unwrap();
|
||||
assert_eq!(back.len(), 1, "the swatch must survive the round trip");
|
||||
assert_eq!(back[0].name, "x".repeat(usize::from(u16::MAX) - 1));
|
||||
assert_eq!(back[0].value, sw[0].value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_components_are_clamped() {
|
||||
let mut f = Vec::new();
|
||||
|
||||
@@ -165,6 +165,20 @@ pub struct PlacedGlyph {
|
||||
}
|
||||
|
||||
impl PlacedGlyph {
|
||||
/// Whether an underline or a strikethrough runs across this glyph.
|
||||
///
|
||||
/// A tab is not drawn, but it is a character with an advance, and the rule runs across its
|
||||
/// whole width as InDesign draws it: forms rule their fill-in lines with underlined tabs, and
|
||||
/// without this they lost every writing line (#1466). The spaces a line break trimmed off the
|
||||
/// end, the break characters themselves and the markers' carriers are not part of the line's
|
||||
/// text and are not spanned.
|
||||
pub fn spans_rules(&self) -> bool {
|
||||
if self.visible {
|
||||
return true;
|
||||
}
|
||||
self.adv > 0.0 && matches!(self.rendered_char, '\t' | designcraft_doc::story::RIGHT_INDENT_TAB)
|
||||
}
|
||||
|
||||
/// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x`
|
||||
/// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`],
|
||||
/// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko
|
||||
@@ -679,10 +693,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),
|
||||
};
|
||||
@@ -1674,10 +1690,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
}
|
||||
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
|
||||
let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent);
|
||||
let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 };
|
||||
let (ind_l, ind_r) = match beside {
|
||||
Some(w) if rtl => (pp.left_indent, pp.right_indent + w),
|
||||
Some(w) => (pp.left_indent + w, pp.right_indent),
|
||||
None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent),
|
||||
// The first-line indent is at the paragraph's start side, which is the right
|
||||
// one in a right-to-left paragraph: applied on the left it moved the line the
|
||||
// wrong way, out of the frame for the hanging indent a numbered list uses
|
||||
// (#1448).
|
||||
None if rtl => (pp.left_indent, pp.right_indent + first_line),
|
||||
None => (pp.left_indent + first_line, pp.right_indent),
|
||||
};
|
||||
let lx0 = x0 + ind_l;
|
||||
let lx1 = x1 - ind_r;
|
||||
|
||||
@@ -7310,3 +7310,58 @@ 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);
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on
|
||||
/// the left it moved the first line the wrong way — out of the frame for the hanging indent a
|
||||
/// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where
|
||||
/// the first line should have been (#1448).
|
||||
#[test]
|
||||
fn an_rtl_first_line_indent_is_at_the_right_edge() {
|
||||
const MM: f64 = 72.0 / 25.4;
|
||||
// Latin text in a right-to-left paragraph: the direction is the paragraph's, and it is the
|
||||
// placement that is being measured, so no Arabic font has to be present.
|
||||
let text = LOREM;
|
||||
let rect = Rect::new(0.0, 0.0, 200.0, 300.0);
|
||||
let para = |dir, align, first, left, right| ParaAttrs {
|
||||
direction: Some(dir),
|
||||
align: Some(align),
|
||||
first_line_indent: Some(first),
|
||||
left_indent: Some(left),
|
||||
right_indent: Some(right),
|
||||
..Default::default()
|
||||
};
|
||||
// The hanging indent of a numbered list, right to left.
|
||||
let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::RightToLeft, Align::Right, -10.0 * MM, 0.0, 10.0 * MM));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() >= 2, "the text wraps: {}", lines.len());
|
||||
let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max);
|
||||
let left_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x).fold(f64::MAX, f64::min);
|
||||
// The first line reaches the frame's right edge; the wrapped lines stop 10 mm short of it.
|
||||
assert!((right_edge(lines[0]) - rect.x1).abs() < 1.0, "first line at the edge: {}", right_edge(lines[0]));
|
||||
assert!((right_edge(lines[1]) - (rect.x1 - 10.0 * MM)).abs() < 1.0, "wrapped line indented: {}", right_edge(lines[1]));
|
||||
// And nothing leaves the frame on the left, which is where the first line used to go.
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
assert!(left_edge(l) >= rect.x0 - 0.01, "line {i} starts inside the frame: {}", left_edge(l));
|
||||
}
|
||||
|
||||
// The same paragraph left to right is unchanged: the first line hangs on the left.
|
||||
let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::LeftToRight, Align::Left, -10.0 * MM, 10.0 * MM, 0.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0]));
|
||||
assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1]));
|
||||
}
|
||||
@@ -262,12 +262,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.selection.cells = None;
|
||||
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
|
||||
st.selection.items.remove(i);
|
||||
// A key object needs another object to align to it.
|
||||
if st.selection.key == Some(id) || st.selection.items.len() < 2 {
|
||||
st.selection.key = None;
|
||||
}
|
||||
} else {
|
||||
st.selection.items.push(id);
|
||||
}
|
||||
st.bump_revision();
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?;
|
||||
let st = s.doc_mut()?;
|
||||
if !st.selection.items.contains(&id) {
|
||||
return Err(super::bad("selection.key", "the key object must be selected"));
|
||||
}
|
||||
if st.selection.items.len() < 2 {
|
||||
return Err(super::bad("selection.key", "a key object needs at least two selected objects"));
|
||||
}
|
||||
st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) };
|
||||
st.bump_revision();
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(t) = st.selection.text {
|
||||
|
||||
@@ -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",
|
||||
@@ -331,7 +331,12 @@ fn pages_param(id: &str, p: &Value, page_count: usize) -> Result<Option<Vec<usiz
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.filter(|n| *n >= 1 && *n <= page_count)
|
||||
.ok_or_else(|| bad(id, format!("bad page {x}")))?;
|
||||
v.push(n - 1);
|
||||
// A page named twice is one page, as `"2,2"` already is: the list says which
|
||||
// pages to export, not how many times. `[2, 2]` exported two copies of page 2
|
||||
// while `"2,2"` exported one.
|
||||
if !v.contains(&(n - 1)) {
|
||||
v.push(n - 1);
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
@@ -371,6 +376,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 +391,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()
|
||||
})
|
||||
}
|
||||
@@ -1326,3 +1336,52 @@ mod pptx_tests {
|
||||
assert!(x.contains("<p:pic>") && !x.contains("prst=\"ellipse\""), "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod page_list_tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn doc(pages: u64) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The same selection written two ways exported different files: `"2,2"` is one page, because
|
||||
/// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2.
|
||||
/// The list says which pages to export, not how many times.
|
||||
#[test]
|
||||
fn a_page_named_twice_is_exported_once_either_way() {
|
||||
let mut s = doc(4);
|
||||
for (pages, want) in [
|
||||
(json!("2,2"), 1),
|
||||
(json!([2, 2]), 1),
|
||||
(json!([2, 2, 2]), 1),
|
||||
(json!("2-2,2"), 1),
|
||||
(json!([1, 2, 1]), 2),
|
||||
(json!("1,2,1"), 2),
|
||||
(json!([4, 1]), 2),
|
||||
(json!("4,1"), 2),
|
||||
(json!("all"), 4),
|
||||
] {
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap();
|
||||
assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}");
|
||||
let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap();
|
||||
assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}");
|
||||
}
|
||||
// The two forms now agree byte for byte, and the pages come out in the document's order
|
||||
// whichever order the list names them in (which is how the export already behaved).
|
||||
let mut file = |pages: Value| {
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap();
|
||||
r["base64"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree");
|
||||
assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order");
|
||||
assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page");
|
||||
// A page that does not exist is still refused, and an empty list still exports nothing.
|
||||
assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err());
|
||||
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::sync::{Arc, Weak};
|
||||
|
||||
use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement};
|
||||
use designcraft_doc::story::floor_char_boundary;
|
||||
use designcraft_doc::{Document, Selection, StoryId, TextSel};
|
||||
use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None,
|
||||
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]",
|
||||
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
|
||||
has_doc, find),
|
||||
cmd!(
|
||||
"find.change",
|
||||
@@ -153,6 +153,9 @@ impl Matcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// A searched text: a story, or one table cell of it, and the byte range inside.
|
||||
type Target = (StoryId, Option<CellAddr>, Range<usize>);
|
||||
|
||||
/// A search owns its original domain, not the selection used to paint the latest hit.
|
||||
/// Weak identity avoids retaining an old document merely for this transient cache.
|
||||
#[derive(Clone)]
|
||||
@@ -163,7 +166,7 @@ pub(crate) struct FindContext {
|
||||
selection: Selection,
|
||||
domain: Domain,
|
||||
matcher: Matcher,
|
||||
targets: Vec<(StoryId, Range<usize>)>,
|
||||
targets: Vec<Target>,
|
||||
order: Vec<usize>,
|
||||
target: usize,
|
||||
offset: usize,
|
||||
@@ -184,21 +187,25 @@ impl FindContext {
|
||||
self.target = 0;
|
||||
self.last_match_end = None;
|
||||
self.order = (0..self.targets.len()).collect();
|
||||
let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none());
|
||||
if self.domain == Domain::Document
|
||||
&& let Some(t) = st.selection.text
|
||||
&& let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story)
|
||||
{
|
||||
let caret = st.selection.text.filter(|_| st.selection.cells.is_none());
|
||||
// A caret in a searched cell starts there; one in a footnote starts at its story.
|
||||
let start = st.selection.text.and_then(|t| {
|
||||
let at = |want: Option<CellAddr>| self.targets.iter().position(|(sid, cell, _)| *sid == t.story && *cell == want);
|
||||
at(t.cell).or_else(|| at(None))
|
||||
});
|
||||
if let Some(i) = start.filter(|_| self.domain == Domain::Document) {
|
||||
self.order.rotate_left(i);
|
||||
}
|
||||
let first = self.order.first().and_then(|i| self.targets.get(*i));
|
||||
self.offset = match first {
|
||||
Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)),
|
||||
Some((_, r)) => r.start,
|
||||
Some((sid, cell, r)) if self.domain != Domain::Selection => {
|
||||
caret.filter(|t| t.story == *sid && t.cell == *cell).map_or(r.start, |t| t.range().end.min(r.end))
|
||||
}
|
||||
Some((_, _, r)) => r.start,
|
||||
None => 0,
|
||||
};
|
||||
if let Some((sid, r)) = first {
|
||||
let text = target_text(&st.doc, *sid, r)?;
|
||||
if let Some((sid, cell, r)) = first {
|
||||
let text = target_text(&st.doc, *sid, *cell, r)?;
|
||||
if text.get(self.offset.saturating_sub(r.start)..).is_none() {
|
||||
return Err(bad("find", "invalid UTF-8 search cursor"));
|
||||
}
|
||||
@@ -209,7 +216,7 @@ impl FindContext {
|
||||
fn advance_target(&mut self) {
|
||||
self.target = self.target.saturating_add(1);
|
||||
self.last_match_end = None;
|
||||
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start);
|
||||
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, _, r)| r.start);
|
||||
}
|
||||
|
||||
fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) {
|
||||
@@ -222,8 +229,8 @@ impl FindContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range<usize>) -> Result<&'a str> {
|
||||
d.story(sid)
|
||||
fn target_text<'a>(d: &'a Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Result<&'a str> {
|
||||
d.text_story(sid, cell)
|
||||
.ok_or_else(|| bad("find", "no such story"))?
|
||||
.text
|
||||
.get(r.clone())
|
||||
@@ -246,7 +253,7 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
|
||||
let targets = match domain {
|
||||
Domain::Explicit(sid) => {
|
||||
let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len();
|
||||
vec![(sid, 0..len)]
|
||||
vec![(sid, None, 0..len)]
|
||||
}
|
||||
Domain::Story | Domain::Selection => {
|
||||
if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) {
|
||||
@@ -255,10 +262,21 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
|
||||
let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?;
|
||||
let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?;
|
||||
let r = if domain == Domain::Selection { t.range() } else { 0..story.len() };
|
||||
target_text(d, t.story, &r)?;
|
||||
vec![(t.story, r)]
|
||||
target_text(d, t.story, None, &r)?;
|
||||
vec![(t.story, None, r)]
|
||||
}
|
||||
Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(),
|
||||
// Table cells hold their own text outside `stories`; search them right after the story that anchors the table.
|
||||
Domain::Document => d
|
||||
.stories
|
||||
.values()
|
||||
.flat_map(|x| {
|
||||
let cells = x.tables.iter().flat_map(move |(id, t)| {
|
||||
let cols = t.ncols().max(1);
|
||||
t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len()))
|
||||
});
|
||||
std::iter::once((x.id, None, 0..x.len())).chain(cells)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let mut context = FindContext {
|
||||
document: Arc::downgrade(d),
|
||||
@@ -285,10 +303,14 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let context = scope(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let mut out = Vec::new();
|
||||
for (sid, r) in &context.targets {
|
||||
let text = target_text(&st.doc, *sid, r)?;
|
||||
for (sid, cell, r) in &context.targets {
|
||||
let text = target_text(&st.doc, *sid, *cell, r)?;
|
||||
for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? {
|
||||
out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}));
|
||||
let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)});
|
||||
if let Some(c) = cell {
|
||||
hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col});
|
||||
}
|
||||
out.push(hit);
|
||||
}
|
||||
}
|
||||
s.find_context = Some(context);
|
||||
@@ -317,8 +339,8 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = scope(s, p)?.targets;
|
||||
s.edit(|d, sel| {
|
||||
let mut count = 0usize;
|
||||
for (sid, r) in targets {
|
||||
let Some(story) = d.story(sid) else { continue };
|
||||
for (sid, cell, r) in targets {
|
||||
let Some(story) = d.text_story(sid, cell) else { continue };
|
||||
// Collect matches first (positions refer to the original text), apply back to front.
|
||||
let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?;
|
||||
let edits: Vec<(std::ops::Range<usize>, String)> = if grep {
|
||||
@@ -330,7 +352,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.into_iter()
|
||||
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
|
||||
.collect();
|
||||
let Some(st) = d.story_mut(sid) else { continue };
|
||||
let Some(st) = d.text_story_mut(sid, cell) else { continue };
|
||||
for (range, new) in edits.iter().rev() {
|
||||
st.replace(range.clone(), new);
|
||||
if !over.is_empty() && !new.is_empty() {
|
||||
@@ -338,7 +360,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) {
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
|
||||
// Repair byte validity once against the final story, without remapping valid offsets.
|
||||
let range = t.range();
|
||||
let start = floor_char_boundary(&st.text, range.start);
|
||||
@@ -365,8 +387,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut context = scope(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let mut hit = None;
|
||||
while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
|
||||
let text = target_text(&st.doc, sid, &r)?;
|
||||
while let Some((sid, cell, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
|
||||
let text = target_text(&st.doc, sid, cell, &r)?;
|
||||
let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?;
|
||||
if text.get(from..).is_none() {
|
||||
return Err(bad("find", "invalid UTF-8 search cursor"));
|
||||
@@ -379,7 +401,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
context.advance_empty(text, m.end, b);
|
||||
continue;
|
||||
}
|
||||
hit = Some((sid, a, b));
|
||||
hit = Some((sid, cell, a, b));
|
||||
context.last_match_end = Some(b);
|
||||
if m.is_empty() {
|
||||
context.advance_empty(text, m.end, b);
|
||||
@@ -390,17 +412,22 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
context.advance_target();
|
||||
}
|
||||
let Some((sid, a, b)) = hit else {
|
||||
let Some((sid, cell, a, b)) = hit else {
|
||||
s.find_context = Some(context);
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied());
|
||||
let cell_json = cell.map(|c| json!({"table": c.table, "row": c.row, "col": c.col}));
|
||||
let stm = s.doc_mut()?;
|
||||
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None });
|
||||
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell });
|
||||
stm.bump_revision();
|
||||
context.acknowledge(stm);
|
||||
s.find_context = Some(context);
|
||||
Ok(json!({"story": sid.0, "start": a, "end": b}))
|
||||
let mut out = json!({"story": sid.0, "start": a, "end": b});
|
||||
if let Some(c) = cell_json {
|
||||
out["cell"] = c;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1007,6 +1034,31 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_scope_searches_and_changes_table_cells() {
|
||||
let (mut s, sid) = scope_session("Ore 12.15 here");
|
||||
scope_select(&mut s, sid, 0, 0);
|
||||
let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap();
|
||||
// The caret sits in the first cell, so Find Next goes to the second cell, then the story.
|
||||
let p = json!({"find": "Ore 12.15", "scope": "document"});
|
||||
let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 };
|
||||
assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}}));
|
||||
assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell))));
|
||||
let next = s.execute("find.next", &p).unwrap();
|
||||
assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}");
|
||||
let hits = s.execute("find.find", &p).unwrap();
|
||||
let hits = hits.as_array().unwrap();
|
||||
assert_eq!(hits.len(), 2);
|
||||
let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect();
|
||||
assert_eq!(in_cell.len(), 1);
|
||||
assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1)));
|
||||
let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap();
|
||||
assert_eq!(n["count"], 2);
|
||||
assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty());
|
||||
assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) {
|
||||
let (mut s, sid) = scope_session("red");
|
||||
scope_select(&mut s, sid, 3, 3);
|
||||
@@ -1051,12 +1103,15 @@ mod tests {
|
||||
p["story"] = json!(sid.0);
|
||||
p["scope"] = json!("selection");
|
||||
}
|
||||
// Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level.
|
||||
let in_cell = if explicit || note { 0 } else { child.matches("red").count() };
|
||||
let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() };
|
||||
s.doc_mut().unwrap().selection = selection.clone();
|
||||
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1);
|
||||
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell);
|
||||
scope_hit(&mut s, &p, sid, 0, 3);
|
||||
s.doc_mut().unwrap().selection = selection.clone();
|
||||
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1);
|
||||
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child);
|
||||
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell);
|
||||
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed);
|
||||
assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets");
|
||||
p["find"] = json!("blue");
|
||||
p["change"] = json!("red");
|
||||
|
||||
@@ -72,7 +72,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"currentPage" => IndexRange::CurrentPage,
|
||||
"toEndOfStory" => IndexRange::ToEndOfStory,
|
||||
"suppressPageRange" => IndexRange::SuppressPageRange,
|
||||
"nextParagraphs" => IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).unwrap_or(1) as u32),
|
||||
"nextParagraphs" => {
|
||||
IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).map_or(1, |x| u32::try_from(x).unwrap_or(u32::MAX)))
|
||||
}
|
||||
"see" => IndexRange::See(target()),
|
||||
"seeAlso" => IndexRange::SeeAlso(target()),
|
||||
r => return Err(bad("index.addReference", format!("unknown range `{r}`"))),
|
||||
@@ -103,8 +105,9 @@ fn resolve(s: &Session, d: &Document, skip: Option<StoryId>) -> Vec<(StoryId, us
|
||||
IndexRange::ToEndOfStory => page(st.len().saturating_sub(1)),
|
||||
IndexRange::NextParagraphs(n) => {
|
||||
let ranges = st.para_ranges();
|
||||
let pi = (st.para_at(pos) + *n as usize).min(ranges.len() - 1);
|
||||
page(ranges[pi].end.saturating_sub(1).max(ranges[pi].start))
|
||||
let n = usize::try_from(*n).unwrap_or(usize::MAX);
|
||||
let pi = st.para_at(pos).saturating_add(n).min(ranges.len().saturating_sub(1));
|
||||
ranges.get(pi).and_then(|r| page(r.end.saturating_sub(1).max(r.start)))
|
||||
}
|
||||
_ => start,
|
||||
};
|
||||
|
||||
@@ -161,14 +161,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Pages…",
|
||||
["Layout", "Pages"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||||
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
|
||||
const ID: &str = "layout.pages.insert";
|
||||
// A count that is given but is not a number of pages used to insert one page:
|
||||
// `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped.
|
||||
let count = match p.get("count").filter(|v| !v.is_null()) {
|
||||
None => 1,
|
||||
Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) {
|
||||
Some(n) => n,
|
||||
None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))),
|
||||
},
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.page_count();
|
||||
// An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end`
|
||||
// pins deliberately.
|
||||
let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1));
|
||||
let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||||
let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||||
s.edit(|d, _| {
|
||||
let ids = d.insert_pages(Some(after), count, parent)?;
|
||||
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
|
||||
@@ -2497,3 +2508,34 @@ mod parent_base_tests {
|
||||
#[cfg(test)]
|
||||
#[path = "parent_delete_tests.rs"]
|
||||
mod parent_delete_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod insert_count_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A count that is given but is not a number of pages used to insert one page anyway:
|
||||
/// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped
|
||||
/// to 9999. A position past the last page appends, which is deliberate.
|
||||
#[test]
|
||||
fn a_page_count_insert_pages_cannot_mean_is_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap();
|
||||
assert_eq!(total(&mut s), 3, "one page added");
|
||||
|
||||
for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] {
|
||||
let e = s.execute("layout.pages.insert", ¶ms).expect_err("refused").to_string();
|
||||
assert!(e.contains("1 to 9999 pages"), "{params}: {e}");
|
||||
}
|
||||
// Nothing was inserted by any of those.
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 3);
|
||||
// A position past the last page still appends, which `insert_pages_bounds.rs` pins.
|
||||
let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap();
|
||||
assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4)));
|
||||
// The positions it does have still work: after the first page of three.
|
||||
let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap();
|
||||
assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6)));
|
||||
}
|
||||
}
|
||||
@@ -686,7 +686,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Window", "Object & Layout", "Align"],
|
||||
None,
|
||||
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread, ids?}",
|
||||
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread (default: keyObject while a key object is set), ids?}",
|
||||
has_selection,
|
||||
align
|
||||
),
|
||||
@@ -708,7 +708,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::always,
|
||||
|s, p| {
|
||||
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
|
||||
s.prefs.polygon_sides = (n as u32).clamp(3, 100);
|
||||
s.prefs.polygon_sides = n.clamp(3, 100) as u32;
|
||||
}
|
||||
if let Some(v) = p.get("starInset").and_then(Value::as_f64) {
|
||||
s.prefs.star_inset = (v / 100.0).clamp(0.0, 1.0);
|
||||
@@ -972,7 +972,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = super::text_param(p, "text");
|
||||
let caret = bool_or(p, "caret", content == "text");
|
||||
let vertical = bool_or(p, "vertical", false);
|
||||
let sides = p.get("sides").and_then(Value::as_u64).map_or(s.prefs.polygon_sides, |v| v as u32).clamp(3, 100);
|
||||
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(u64::from(s.prefs.polygon_sides)).clamp(3, 100) as u32;
|
||||
let inset = p.get("starInset").and_then(Value::as_f64).map_or(s.prefs.star_inset, |v| v / 100.0).clamp(0.0, 1.0);
|
||||
let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
|
||||
s.edit(|d, sel| {
|
||||
@@ -2517,7 +2517,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
|
||||
let o = &mut tf.options;
|
||||
if let Some(v) = p.get("columns").and_then(Value::as_u64) {
|
||||
o.columns = (v as u32).clamp(1, 40);
|
||||
o.columns = v.clamp(1, 40) as u32;
|
||||
}
|
||||
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
|
||||
o.gutter = v.max(0.0);
|
||||
@@ -3074,8 +3074,18 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option<ItemId>)
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let edge = str_param(p, "edge").unwrap_or("left").to_string();
|
||||
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| if ids.len() > 1 { "selection".into() } else { "page".into() });
|
||||
let key = s.doc()?.selection.key;
|
||||
// A key object counts only while it is one of several items aligned. With one, InDesign's
|
||||
// Align To switches to Key Object, so it is also the default target.
|
||||
let key = s.doc()?.selection.key.filter(|k| ids.len() > 1 && ids.contains(k));
|
||||
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| {
|
||||
if key.is_some() {
|
||||
"keyObject".into()
|
||||
} else if ids.len() > 1 {
|
||||
"selection".into()
|
||||
} else {
|
||||
"page".into()
|
||||
}
|
||||
});
|
||||
s.edit(|d, _| {
|
||||
let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?;
|
||||
for id in &ids {
|
||||
@@ -3788,6 +3798,21 @@ mod polygon_tests {
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_sides_clamp_before_narrowing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967301u64})).unwrap();
|
||||
assert_eq!(r["sides"], 100);
|
||||
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967297u64})).unwrap();
|
||||
assert_eq!(r["sides"], 100);
|
||||
let id =
|
||||
s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 4294967301u64, "starInset": 0})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4498,6 +4523,21 @@ mod column_rule_tests {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(opts(&s), before);
|
||||
}
|
||||
|
||||
/// A column count past `u32::MAX` was cast to `u32` before the clamp, so
|
||||
/// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40.
|
||||
#[test]
|
||||
fn huge_column_count_clamps_to_the_maximum() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap();
|
||||
let id = ItemId(r["id"].as_u64().unwrap());
|
||||
let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns;
|
||||
for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] {
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap();
|
||||
assert_eq!(columns(&s), want, "columns {given}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -297,7 +297,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Cell Options",
|
||||
[],
|
||||
None,
|
||||
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
|
||||
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
|
||||
in_table,
|
||||
set_cell
|
||||
),
|
||||
@@ -971,6 +971,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
};
|
||||
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
|
||||
// Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is
|
||||
// composed so far, which the IDML import reports (#1468).
|
||||
let rotation = match p.get("rotation").filter(|v| !v.is_null()) {
|
||||
Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) {
|
||||
Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d),
|
||||
_ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
|
||||
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
|
||||
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
|
||||
@@ -1021,6 +1030,9 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = first_baseline_min {
|
||||
cell.first_baseline_min = v;
|
||||
}
|
||||
if let Some(v) = rotation {
|
||||
cell.rotation = v;
|
||||
}
|
||||
if let Some(tx) = &text {
|
||||
let len = cell.text.len();
|
||||
cell.text.replace(0..len, tx);
|
||||
@@ -1393,14 +1405,14 @@ fn parse_alt(v: &Value) -> Option<AltFills> {
|
||||
}
|
||||
let d = AltFills::default();
|
||||
Some(AltFills {
|
||||
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32),
|
||||
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)),
|
||||
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
|
||||
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
|
||||
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32),
|
||||
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)),
|
||||
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
|
||||
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
|
||||
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32),
|
||||
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32),
|
||||
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
|
||||
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1548,7 +1560,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter_map(|(r, c)| {
|
||||
let cell = t.cell(r, c)?;
|
||||
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
|
||||
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
|
||||
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
@@ -1832,6 +1844,16 @@ mod style_tests {
|
||||
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
|
||||
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
|
||||
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
|
||||
// Cell Options › Text › Rotation: carried and written back, composed horizontally so far,
|
||||
// which the IDML import reports (#1468).
|
||||
for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] {
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}"));
|
||||
let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone();
|
||||
assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}");
|
||||
}
|
||||
for bad in [45, 1, 100] {
|
||||
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
|
||||
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
|
||||
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Hyperlink Destination…",
|
||||
["Type", "Hyperlinks & Cross-References"],
|
||||
None,
|
||||
"{name} — a text anchor at the insertion point",
|
||||
"{name} — a text anchor at the insertion point; the name is the document's, so it must not be one another anchor already has",
|
||||
in_story_text,
|
||||
create_anchor
|
||||
),
|
||||
@@ -103,6 +103,14 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn create_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string();
|
||||
if name.trim().is_empty() {
|
||||
return Err(bad("anchor.create", "a text anchor needs a name (a cross-reference and a hyperlink find it by that name)"));
|
||||
}
|
||||
// Two anchors of the same name made every reference to it ambiguous: `xref.insert` and
|
||||
// `hyperlink.create` take the first they find, and a file writes both under one name.
|
||||
if s.doc()?.doc.stories.values().any(|st| st.anchors.iter().any(|a| a.name == name)) {
|
||||
return Err(bad("anchor.create", format!("a text anchor named `{name}` is already in this document (anchor.list names them)")));
|
||||
}
|
||||
s.edit(|d, sel| {
|
||||
let t = sel.text.ok_or_else(|| bad("anchor.create", "no insertion point"))?;
|
||||
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
@@ -215,6 +223,44 @@ mod tests {
|
||||
assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)");
|
||||
}
|
||||
|
||||
/// Two text anchors of the same name made every reference to it ambiguous: `xref.insert
|
||||
/// {"anchor": "A"}` and `hyperlink.create {"anchor": "A"}` take the first they find, and an
|
||||
/// exported file writes both destinations under one name.
|
||||
#[test]
|
||||
fn a_text_anchor_name_is_the_documents_so_it_is_not_reused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Photo one here"})).unwrap();
|
||||
let select = |s: &mut Session, story: serde_json::Value, from: usize| {
|
||||
s.execute("text.select", &json!({"story": story, "anchor": from, "focus": from})).unwrap();
|
||||
};
|
||||
select(&mut s, a["story"].clone(), 0);
|
||||
s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap();
|
||||
|
||||
// The same name again, in the same story and in another one.
|
||||
select(&mut s, a["story"].clone(), 10);
|
||||
let e = s.execute("anchor.create", &json!({"name": "Photo 1"})).expect_err("refused").to_string();
|
||||
assert!(e.contains("already in this document"), "{e}");
|
||||
let sp = s.doc().unwrap().doc.page_loc(1).unwrap().0;
|
||||
let b = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Photo two"})).unwrap();
|
||||
select(&mut s, b["story"].clone(), 0);
|
||||
assert!(s.execute("anchor.create", &json!({"name": "Photo 1"})).is_err(), "not in another story either");
|
||||
// A name of spaces is not a name.
|
||||
assert!(s.execute("anchor.create", &json!({"name": " "})).is_err());
|
||||
assert!(s.execute("anchor.create", &json!({"name": ""})).is_err());
|
||||
|
||||
// One anchor, so a reference by name can only mean that one.
|
||||
let anchors = s.execute("anchor.list", &json!({})).unwrap();
|
||||
assert_eq!(anchors.as_array().unwrap().len(), 1, "{anchors}");
|
||||
// Another name is fine.
|
||||
s.execute("anchor.create", &json!({"name": "Photo 2"})).unwrap();
|
||||
assert_eq!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
|
||||
// The check is on the name, not on where it is: a name that differs only in case is a
|
||||
// different name, as it is everywhere else in the document.
|
||||
select(&mut s, b["story"].clone(), 0);
|
||||
s.execute("anchor.create", &json!({"name": "photo 1"})).expect("a different name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_reference_into_a_story_with_a_running_header_composes() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1651,3 +1651,108 @@ fn a_line_fits_when_its_baseline_does() {
|
||||
assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame");
|
||||
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
|
||||
}
|
||||
|
||||
/// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key
|
||||
/// object, and Align to Key Object leaves it in place. A second click on it drops the key.
|
||||
#[test]
|
||||
fn a_click_in_the_selection_makes_the_key_object() {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.set_tool("selection");
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
// On the part of the key object that the aligned object never covers, away from the handles.
|
||||
let click = |s: &mut Session| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 280.0, 160.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 160.0), v).unwrap();
|
||||
};
|
||||
click(&mut s);
|
||||
assert_eq!(s.doc().unwrap().selection.items.len(), 2, "the click keeps both selected");
|
||||
// The key is the right-hand object, so aligning left edges to the selection would move it.
|
||||
s.execute("object.align", &json!({"edge": "left", "to": "keyObject"})).unwrap();
|
||||
let x0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().x0;
|
||||
assert!((x0(&s, b) - 200.0).abs() < 1e-6, "the key object moved to {}", x0(&s, b));
|
||||
assert!((x0(&s, a) - 200.0).abs() < 1e-6, "the other object is at {}", x0(&s, a));
|
||||
// The Align panel and the Control panel send no target: the key object is the default one.
|
||||
s.execute("object.align", &json!({"edge": "top"})).unwrap();
|
||||
let y0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().y0;
|
||||
assert!((y0(&s, b) - 120.0).abs() < 1e-6, "the key object moved to {}", y0(&s, b));
|
||||
assert!((y0(&s, a) - 120.0).abs() < 1e-6, "the other object is at {}", y0(&s, a));
|
||||
click(&mut s);
|
||||
assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key object drops the key");
|
||||
}
|
||||
|
||||
/// Dragging one of several selected objects moves them all and makes no key object.
|
||||
#[test]
|
||||
fn dragging_the_selection_makes_no_key_object() {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.set_tool("selection");
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 230.0, 125.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 135.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 135.0), v).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, None);
|
||||
}
|
||||
|
||||
/// Shift-clicking the key object out of the selection drops the key.
|
||||
#[test]
|
||||
fn deselecting_the_key_object_drops_the_key() {
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("selection.key", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(designcraft_doc::ItemId(b)));
|
||||
s.execute("selection.toggle", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, None);
|
||||
}
|
||||
|
||||
/// A key object needs another object to align to: with one object selected there is nothing to key
|
||||
/// against, and the stored key would turn that object into the target as soon as a second one joins.
|
||||
#[test]
|
||||
fn a_single_selected_object_cannot_be_the_key_object() {
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
|
||||
assert!(s.execute("selection.key", &json!({"id": a})).is_err());
|
||||
assert_eq!(s.doc().unwrap().selection.key, None);
|
||||
s.execute("selection.toggle", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, None, "the second object joining must not find a key already set");
|
||||
}
|
||||
|
||||
/// With the selection down to the key object alone, Align no longer has a key object: a single
|
||||
/// object aligns to the page, as before.
|
||||
#[test]
|
||||
fn a_lone_key_object_aligns_to_the_page() {
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("selection.key", &json!({"id": b})).unwrap();
|
||||
s.execute("selection.toggle", &json!({"id": a})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, None);
|
||||
s.execute("object.align", &json!({"edge": "left"})).unwrap();
|
||||
let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
|
||||
assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
|
||||
}
|
||||
|
||||
/// Aligning one object alone (`ids`) while it is the key object of a larger selection also takes
|
||||
/// the page.
|
||||
#[test]
|
||||
fn aligning_the_key_object_alone_takes_the_page() {
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("selection.key", &json!({"id": b})).unwrap();
|
||||
s.execute("object.align", &json!({"edge": "left", "ids": [b]})).unwrap();
|
||||
let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
|
||||
assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
|
||||
}
|
||||
@@ -1097,3 +1097,26 @@ fn convert_rows_keeps_merged_cells_on_one_side() {
|
||||
assert!(!convert_enabled(&s, "Body"));
|
||||
check(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_fill_counts_saturate_instead_of_wrapping() {
|
||||
let (mut s, _, _) = session_with_frame();
|
||||
s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap();
|
||||
s.execute("table.options", &json!({"altRows": {"first": 4294967297u64, "next": 1, "skipFirst": 4294967296u64, "skipLast": 9999999999u64}}))
|
||||
.unwrap();
|
||||
let t = s.execute("table.get", &json!({})).unwrap();
|
||||
let a = &t["options"]["altRows"];
|
||||
assert_eq!(a["first"], u32::MAX);
|
||||
assert_eq!(a["skipFirst"], u32::MAX);
|
||||
assert_eq!(a["skipLast"], u32::MAX);
|
||||
assert_eq!(a["next"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_next_paragraphs_saturates_instead_of_wrapping() {
|
||||
let (mut s, _, _) = session_with_frame();
|
||||
s.execute("index.addReference", &json!({"topic": "Intro", "range": "nextParagraphs", "paragraphs": 4294967296u64})).unwrap();
|
||||
let l = s.execute("index.list", &json!({})).unwrap();
|
||||
assert_eq!(l[0]["range"]["type"], "nextParagraphs");
|
||||
assert_eq!(l[0]["range"]["value"], u32::MAX);
|
||||
}
|
||||
@@ -286,6 +286,8 @@ pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String {
|
||||
whole += 1.0;
|
||||
rest = 0.0;
|
||||
}
|
||||
// Decide the sign from the rounded value so float noise doesn't print "-0p0".
|
||||
let neg = neg && (whole > 0.0 || rest > 0.0);
|
||||
let sep = if unit == Unit::Picas { 'p' } else { 'c' };
|
||||
format!("{}{}{}{}", if neg { "-" } else { "" }, whole as i64, sep, trim(rest))
|
||||
}
|
||||
@@ -304,6 +306,14 @@ mod tests {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Tiny negatives that round to zero must not print a sign.
|
||||
#[test]
|
||||
fn tiny_negative_picas_ciceros_have_no_sign() {
|
||||
assert_eq!(format_measure(-0.0004, Unit::Picas), "0p0");
|
||||
assert_eq!(format_measure(-0.0004, Unit::Ciceros), "0c0");
|
||||
assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0");
|
||||
}
|
||||
|
||||
/// Inches show four decimals, as InDesign's fields do; other units three.
|
||||
#[test]
|
||||
fn inches_show_four_decimals() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+43
-11
@@ -295,6 +295,10 @@ struct Importer<'r> {
|
||||
parents: Vec<Spread>,
|
||||
/// Things read but not kept as they were, for the import report.
|
||||
warnings: Vec<String>,
|
||||
/// Cells whose text is rotated (Cell Options › Text › Rotation): the angle is kept and
|
||||
/// written back, but only 0 is composed, so a narrow header column of vertical labels comes
|
||||
/// in horizontal and wraps mid-word (#1468).
|
||||
rotated_cells: usize,
|
||||
parent_ids: HashMap<String, SpreadId>,
|
||||
spreads: Vec<Spread>,
|
||||
page_index: HashMap<String, usize>,
|
||||
@@ -387,13 +391,10 @@ fn mojikumi_row(e: &El) -> Result<designcraft_doc::cjk::MojikumiAki> {
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// The numbers in a whitespace-separated list; empty when any number is NaN or infinite (JSON
|
||||
/// can't store them, and dropping just those would shift the rest).
|
||||
fn nums(s: &str) -> Vec<f64> {
|
||||
s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
|
||||
}
|
||||
|
||||
/// Like [`nums`], but empty when any number is NaN or infinite.
|
||||
fn finite_nums(s: &str) -> Vec<f64> {
|
||||
let v = nums(s);
|
||||
let v: Vec<f64> = s.split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() }
|
||||
}
|
||||
|
||||
@@ -536,6 +537,7 @@ impl<'r> Importer<'r> {
|
||||
story_ids: HashMap::new(),
|
||||
parents: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
rotated_cells: 0,
|
||||
parent_ids: HashMap::new(),
|
||||
spreads: Vec::new(),
|
||||
page_index: HashMap::new(),
|
||||
@@ -791,6 +793,12 @@ impl<'r> Importer<'r> {
|
||||
include_prefix: e.get("IncludeSectionPrefix") == Some("true"),
|
||||
});
|
||||
}
|
||||
if self.rotated_cells > 0 {
|
||||
let n = self.rotated_cells;
|
||||
self.warnings.push(format!(
|
||||
"{n} table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally"
|
||||
));
|
||||
}
|
||||
self.sections.sort_by_key(|s| s.start);
|
||||
self.sections.dedup_by_key(|s| s.start);
|
||||
if self.sections.first().is_none_or(|s| s.start != 0) {
|
||||
@@ -895,6 +903,15 @@ impl<'r> Importer<'r> {
|
||||
_ if v.len() >= 4 => Color::cmyk((v[0] / 100.0) as f32, (v[1] / 100.0) as f32, (v[2] / 100.0) as f32, (v[3] / 100.0) as f32),
|
||||
_ => return None,
|
||||
};
|
||||
// Huge values (1e300) overflow `f32` to infinity.
|
||||
let parts = match c {
|
||||
Color::Rgb { r, g, b } => [r, g, b, 0.0],
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
|
||||
};
|
||||
if !parts.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let ty = if e.get("Model") == Some("Spot") { ColorType::Spot } else { ColorType::Process };
|
||||
Some((c, ty))
|
||||
}
|
||||
@@ -947,14 +964,14 @@ impl<'r> Importer<'r> {
|
||||
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
|
||||
let kind = match e.local() {
|
||||
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
|
||||
bands: finite_nums(e.get("StripeArray").unwrap_or(""))
|
||||
bands: nums(e.get("StripeArray").unwrap_or(""))
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0))
|
||||
.collect(),
|
||||
},
|
||||
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: finite_nums(e.get("DashArray").unwrap_or("")) },
|
||||
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums(e.get("DashArray").unwrap_or("")) },
|
||||
_ => designcraft_doc::StrokeType::Dotted,
|
||||
};
|
||||
let _ = id;
|
||||
@@ -2170,9 +2187,12 @@ impl<'r> Importer<'r> {
|
||||
}
|
||||
// Table-local options override the named table style. Cell styles are already
|
||||
// materialized independently, so their edges retain their higher precedence.
|
||||
let mut border_unset = style.border.is_none() && style.borders.iter().all(|b| b.is_empty());
|
||||
let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone());
|
||||
for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) {
|
||||
self.cell_stroke_attrs(e, side).apply_to(border);
|
||||
let attrs = self.cell_stroke_attrs(e, side);
|
||||
border_unset &= attrs.is_empty();
|
||||
attrs.apply_to(border);
|
||||
}
|
||||
t.options.border = borders[0].clone();
|
||||
t.options.borders = borders.map(Some);
|
||||
@@ -2235,6 +2255,9 @@ impl<'r> Importer<'r> {
|
||||
cell.first_baseline_min = min.clamp(0.0, 8640.0);
|
||||
}
|
||||
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
|
||||
if cell.rotation != 0.0 {
|
||||
self.rotated_cells += 1;
|
||||
}
|
||||
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
|
||||
let attrs = self.cell_stroke_attrs(ce, side);
|
||||
attrs.apply_to(&mut cell.strokes[i]);
|
||||
@@ -2243,6 +2266,10 @@ impl<'r> Importer<'r> {
|
||||
cell.stroke_priorities[i] = priority;
|
||||
// Preserve style-defined edges and #102's positive-priority local overrides.
|
||||
cell.border_overrides[i] |= priority > 0;
|
||||
// No table or table style border is given anywhere, so the built-in 1 pt default must not
|
||||
// draw over an outer edge the cell explicitly hides (weight 0 or the None swatch), as InDesign draws nothing.
|
||||
let hidden = attrs.weight == Some(0.0) || attrs.color.as_deref() == Some(swatch::NONE);
|
||||
cell.border_overrides[i] |= border_unset && hidden;
|
||||
}
|
||||
if let Some(slot) = t.cell_mut(r, c) {
|
||||
*slot = cell;
|
||||
@@ -2856,7 +2883,7 @@ impl<'r> Importer<'r> {
|
||||
{
|
||||
it.fill.gradient_angle = Some(a);
|
||||
}
|
||||
let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| finite_nums(&s));
|
||||
let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| nums(&s));
|
||||
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength"))
|
||||
&& len > 0.0
|
||||
{
|
||||
@@ -2874,7 +2901,7 @@ impl<'r> Importer<'r> {
|
||||
{
|
||||
it.stroke.gradient_angle = Some(a);
|
||||
}
|
||||
let start: Option<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s));
|
||||
let start: Option<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| nums(&s));
|
||||
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength"))
|
||||
&& len > 0.0
|
||||
{
|
||||
@@ -3702,6 +3729,11 @@ fn path_capped(pg: &El, max: usize) -> PathData {
|
||||
let closed = g.get("PathOpen") != Some("true");
|
||||
let mut anchors = Vec::new();
|
||||
if let Some(arr) = g.find("PathPointArray") {
|
||||
// A NaN or infinite point drops its subpath (JSON can't store it).
|
||||
let non_finite = |s: Option<&str>| s.is_some_and(|s| s.split_whitespace().any(|v| v.parse::<f64>().is_ok_and(|x| !x.is_finite())));
|
||||
if arr.find_all("PathPointType").take(left).any(|p| ["Anchor", "LeftDirection", "RightDirection"].iter().any(|k| non_finite(p.get(k)))) {
|
||||
continue;
|
||||
}
|
||||
for p in arr.find_all("PathPointType").take(left) {
|
||||
let Some(a) = parse_point(p.get("Anchor")) else { continue };
|
||||
let hin = parse_point(p.get("LeftDirection")).unwrap_or(a);
|
||||
|
||||
@@ -144,16 +144,37 @@ pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Tabl
|
||||
pub const OBJECT_BUILTINS: &[(&str, &str)] =
|
||||
&[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")];
|
||||
|
||||
/// Our style name → IDML `Name` (groups: `A/B` → `A:B`).
|
||||
/// Our style name → IDML `Name` (groups: `A/B` → `A:B`; a literal `:` or `\` is escaped as `\:`
|
||||
/// or `\\`, so it is not read back as a group separator).
|
||||
pub fn style_name_out(builtins: &[(&str, &str)], name: &str) -> String {
|
||||
builtins.iter().find(|(o, _)| *o == name).map(|(_, i)| i.to_string()).unwrap_or_else(|| name.replace('/', ":"))
|
||||
builtins
|
||||
.iter()
|
||||
.find(|(o, _)| *o == name)
|
||||
.map(|(_, i)| i.to_string())
|
||||
.unwrap_or_else(|| name.replace('\\', "\\\\").replace(':', "\\:").replace('/', ":"))
|
||||
}
|
||||
/// IDML `Name` → our style name.
|
||||
pub fn style_name_in(builtins: &[(&str, &str)], name: &str) -> String {
|
||||
if let Some((o, _)) = builtins.iter().find(|(_, i)| *i == name) {
|
||||
return o.to_string();
|
||||
}
|
||||
name.strip_prefix("$ID/").unwrap_or(name).replace(':', "/")
|
||||
let name = name.strip_prefix("$ID/").unwrap_or(name);
|
||||
let mut out = String::with_capacity(name.len());
|
||||
let mut chars = name.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\\' => match chars.clone().next() {
|
||||
Some(next @ ('\\' | ':')) => {
|
||||
out.push(next);
|
||||
chars.next();
|
||||
}
|
||||
_ => out.push('\\'),
|
||||
},
|
||||
':' => out.push('/'),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `ParagraphStyle/…` Self id for one of our names.
|
||||
pub fn style_self(kind: &str, builtins: &[(&str, &str)], name: &str) -> String {
|
||||
|
||||
@@ -1100,6 +1100,35 @@ fn imports_and_round_trips_explicit_cell_border_overrides() {
|
||||
assert_eq!(roundtrip.cell(0, 1).unwrap().border_overrides, [false; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicitly_hidden_cell_edges_hide_the_default_table_border() {
|
||||
// No table border or table style is given, and every cell edge is explicitly hidden (issue #1623):
|
||||
// the first cell by weight 0, the second by the None swatch.
|
||||
let cell = |name: &str, attr: &str, value: &str| {
|
||||
format!(
|
||||
r#"<Cell Name="{name}" LeftEdge{attr}="{value}" RightEdge{attr}="{value}" TopEdge{attr}="{value}" BottomEdge{attr}="{value}">
|
||||
<ParagraphStyleRange><CharacterStyleRange><Content>X</Content></CharacterStyleRange></ParagraphStyleRange></Cell>"#
|
||||
)
|
||||
};
|
||||
let story = format!(
|
||||
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange>
|
||||
<Table HeaderRowCount="0" FooterRowCount="0">
|
||||
<Row Name="0" MinimumHeight="24"/>
|
||||
<Column Name="0" SingleColumnWidth="60"/><Column Name="1" SingleColumnWidth="60"/>
|
||||
{}{}
|
||||
</Table></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#,
|
||||
cell("0:0", "StrokeWeight", "0"),
|
||||
cell("1:0", "StrokeColor", "Swatch/None")
|
||||
);
|
||||
let d = import_idml(&fixture_with_story(&story)).unwrap();
|
||||
d.check().unwrap();
|
||||
let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
|
||||
for c in 0..2 {
|
||||
assert_eq!(table.cell(0, c).unwrap().border_overrides, [true; 4], "cell {c} suppresses the table border");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_footnotes_and_options() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
@@ -4368,3 +4397,144 @@ 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}");
|
||||
}
|
||||
|
||||
/// Non-finite numbers in colour values, page bounds and item transforms (NaN, infinities and
|
||||
/// values like 1e300 that overflow `f32`) never reach the model: JSON would write them as null.
|
||||
#[test]
|
||||
fn non_finite_number_lists_never_reach_the_model() {
|
||||
let graphic = GRAPHIC.replace(r#"ColorValue="255 0 0""#, r#"ColorValue="NaN 1e300 inf""#);
|
||||
let spread = SPREAD
|
||||
.replace(r#"Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500""#, r#"Self="p2" AppliedMaster="m1" GeometricBounds="0 0 NaN 500""#)
|
||||
.replace(r#"ItemTransform="1 0 0 1 50 -300""#, r#"ItemTransform="1 0 0 1 inf -300""#);
|
||||
let bytes = zip_files(&[
|
||||
("designmap.xml", DESIGNMAP),
|
||||
("Resources/Graphic.xml", &graphic),
|
||||
("Resources/Styles.xml", STYLES),
|
||||
("Resources/Preferences.xml", PREFS),
|
||||
("MasterSpreads/MasterSpread_m1.xml", MASTER),
|
||||
("Spreads/Spread_sp1.xml", &spread),
|
||||
("Stories/Story_s1.xml", STORY),
|
||||
]);
|
||||
let doc = import_idml(&bytes).unwrap();
|
||||
for s in &doc.swatches {
|
||||
if let SwatchValue::Color { color, .. } | SwatchValue::Paper { color } = &s.value {
|
||||
let parts: Vec<f32> = match *color {
|
||||
Color::Rgb { r, g, b } => vec![r, g, b],
|
||||
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
|
||||
Color::Gray { k } => vec![k],
|
||||
};
|
||||
assert!(parts.iter().all(|v| v.is_finite()), "swatch {} is not finite: {color:?}", s.name);
|
||||
}
|
||||
}
|
||||
for sp in doc.spreads.iter().chain(&doc.parents) {
|
||||
for p in &sp.pages {
|
||||
assert!(p.width.is_finite() && p.height.is_finite() && p.x.is_finite(), "page {p:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(doc.non_finite_item(), None);
|
||||
}
|
||||
|
||||
/// Cell Options › Text › Rotation: the angle survives the round trip, and the import says it is
|
||||
/// not composed — narrow header columns of vertical labels come in horizontal and wrap mid-word,
|
||||
/// and nothing said so (#1468).
|
||||
#[test]
|
||||
fn rotated_cell_text_round_trips_and_the_import_says_it_is_not_composed() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap();
|
||||
let mut t = designcraft_doc::Table::new(7, 1, 3, 0, 0, 300.0);
|
||||
t.cell_mut(0, 0).unwrap().rotation = 90.0;
|
||||
t.cell_mut(0, 1).unwrap().rotation = 270.0;
|
||||
d.story_mut(sid).unwrap().insert_table(0, t);
|
||||
let bytes = export_idml(&d);
|
||||
let story = zip_text(&bytes, "Stories/");
|
||||
assert!(story.contains(r#"RotationAngle="90""#) && story.contains(r#"RotationAngle="270""#), "{story}");
|
||||
|
||||
let back = import_idml_report(&bytes, &|_| None).unwrap();
|
||||
let t = back.document.stories.values().find(|st| !st.tables.is_empty()).unwrap().tables.values().next().unwrap().clone();
|
||||
assert_eq!((t.cell(0, 0).unwrap().rotation, t.cell(0, 1).unwrap().rotation, t.cell(0, 2).unwrap().rotation), (90.0, 270.0, 0.0));
|
||||
let about: Vec<&String> = back.warnings.iter().filter(|w| w.contains("rotated text")).collect();
|
||||
assert_eq!(about.len(), 1, "one line for the file, not one per cell: {:?}", back.warnings);
|
||||
assert!(about[0].starts_with("2 table cell(s)"), "{}", about[0]);
|
||||
assert!(about[0].contains("composed horizontally"), "{}", about[0]);
|
||||
|
||||
// A file with no rotated cell says nothing.
|
||||
let mut plain = Document::new(&NewDocument::default());
|
||||
let (_, sid) =
|
||||
plain.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), plain.default_layer(), "", ParaFormat::default()).unwrap();
|
||||
plain.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(8, 1, 2, 0, 0, 200.0));
|
||||
let quiet = import_idml_report(&export_idml(&plain), &|_| None).unwrap();
|
||||
assert!(quiet.warnings.iter().all(|w| !w.contains("rotated text")), "{:?}", quiet.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_names_with_a_literal_colon_survive_a_round_trip() {
|
||||
use designcraft_doc::styles::{CellStyle, CharacterStyle, ObjectStyle, ParagraphStyle, TableStyle};
|
||||
let names = ["Title: Big", "Group/Child: X", "Back\\slash: \\:"];
|
||||
let mut d = small_doc();
|
||||
for n in names {
|
||||
let st = d.styles_mut();
|
||||
st.paragraph.push(ParagraphStyle {
|
||||
name: n.into(),
|
||||
based_on: None,
|
||||
next_style: None,
|
||||
para: Default::default(),
|
||||
chars: Default::default(),
|
||||
shortcut: String::new(),
|
||||
});
|
||||
st.character.push(CharacterStyle { name: n.into(), based_on: None, chars: Default::default(), shortcut: String::new() });
|
||||
st.cell.push(CellStyle { name: n.into(), ..Default::default() });
|
||||
st.table.push(TableStyle { name: n.into(), ..Default::default() });
|
||||
st.object.push(ObjectStyle { name: n.into(), ..Default::default() });
|
||||
}
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
for n in names {
|
||||
assert!(
|
||||
back.styles.paragraph.iter().any(|s| s.name == n),
|
||||
"paragraph {n:?}: {:?}",
|
||||
back.styles.paragraph.iter().map(|s| &s.name).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(back.styles.character.iter().any(|s| s.name == n), "character {n:?}");
|
||||
assert!(back.styles.cell.iter().any(|s| s.name == n), "cell {n:?}");
|
||||
assert!(back.styles.table.iter().any(|s| s.name == n), "table {n:?}");
|
||||
assert!(back.styles.object.iter().any(|s| s.name == n), "object {n:?}");
|
||||
}
|
||||
// A group is still written as an IDML `:` group path.
|
||||
assert_eq!(names::style_name_out(names::PARA_BUILTINS, "Group/Child"), "Group:Child");
|
||||
for n in names {
|
||||
assert_eq!(names::style_name_in(names::PARA_BUILTINS, &names::style_name_out(names::PARA_BUILTINS, n)), n);
|
||||
}
|
||||
}
|
||||
@@ -1629,6 +1629,21 @@ fn is_rgb_icc(icc: &[u8]) -> bool {
|
||||
icc.get(16..20) == Some(b"RGB ".as_slice())
|
||||
}
|
||||
|
||||
/// Whether an ICC profile describes grey.
|
||||
fn is_grey_icc(icc: &[u8]) -> bool {
|
||||
icc.get(16..20) == Some(b"GRAY".as_slice())
|
||||
}
|
||||
|
||||
/// Whether `data` decodes to grey (with or without alpha), read from its header.
|
||||
fn is_grey(data: &[u8]) -> bool {
|
||||
use image::ImageDecoder;
|
||||
image::ImageReader::new(std::io::Cursor::new(data))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|r| r.into_decoder().ok())
|
||||
.is_some_and(|dec| !dec.color_type().has_color())
|
||||
}
|
||||
|
||||
/// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets
|
||||
/// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG
|
||||
/// without a profile stays DeviceGray or DeviceCMYK.
|
||||
@@ -1668,6 +1683,10 @@ fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, mode: RasterMo
|
||||
_ => None,
|
||||
};
|
||||
direct.or_else(|| {
|
||||
// A grey TIFF, BMP… stays DeviceGray, as a grey PNG or JPEG does (#1426).
|
||||
if let Some(grey) = GreyImage::new(data) {
|
||||
return Image::from_custom(grey, i).ok();
|
||||
}
|
||||
// TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB.
|
||||
if mode.tag_rgb {
|
||||
return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok());
|
||||
@@ -1722,6 +1741,61 @@ impl krilla::image::CustomImage for SrgbImage {
|
||||
}
|
||||
}
|
||||
|
||||
/// A grey raster as a DeviceGray image (ICCBased when the file has a grey profile of its own), so
|
||||
/// a page that only holds black prints as black, not as RGB.
|
||||
#[derive(Clone, Hash)]
|
||||
struct GreyImage {
|
||||
grey: Arc<Vec<u8>>,
|
||||
/// Only when some pixel is not opaque.
|
||||
alpha: Option<Arc<Vec<u8>>>,
|
||||
size: (u32, u32),
|
||||
icc: Option<Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl GreyImage {
|
||||
/// `None` when `data` isn't grey or can't be decoded.
|
||||
fn new(data: &[u8]) -> Option<Self> {
|
||||
if !is_grey(data) {
|
||||
return None;
|
||||
}
|
||||
let img = image::load_from_memory(data).ok()?.to_luma_alpha8();
|
||||
let size = img.dimensions();
|
||||
let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?;
|
||||
if pixels == 0 {
|
||||
return None;
|
||||
}
|
||||
let (mut grey, mut alpha) = (Vec::with_capacity(pixels), Vec::with_capacity(pixels));
|
||||
for p in img.pixels() {
|
||||
grey.push(p.0[0]);
|
||||
alpha.push(p.0[1]);
|
||||
}
|
||||
let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha));
|
||||
let icc = embedded_icc(data).filter(|p| is_grey_icc(p)).map(Arc::new);
|
||||
Some(Self { grey: Arc::new(grey), alpha, size, icc })
|
||||
}
|
||||
}
|
||||
|
||||
impl krilla::image::CustomImage for GreyImage {
|
||||
fn color_channel(&self) -> &[u8] {
|
||||
&self.grey
|
||||
}
|
||||
fn alpha_channel(&self) -> Option<&[u8]> {
|
||||
self.alpha.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
fn bits_per_component(&self) -> krilla::image::BitsPerComponent {
|
||||
krilla::image::BitsPerComponent::Eight
|
||||
}
|
||||
fn size(&self) -> (u32, u32) {
|
||||
self.size
|
||||
}
|
||||
fn icc_profile(&self) -> Option<&[u8]> {
|
||||
self.icc.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
fn color_space(&self) -> krilla::image::ImageColorspace {
|
||||
krilla::image::ImageColorspace::Luma
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a
|
||||
/// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for
|
||||
/// the output intent's press, and tagging them with another CMYK profile would let a RIP
|
||||
@@ -1768,11 +1842,18 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
|
||||
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
|
||||
return None;
|
||||
}
|
||||
let rgb = img.to_rgb8();
|
||||
let mut buf = Vec::new();
|
||||
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
|
||||
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
|
||||
jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode)
|
||||
// A grey image stays a grey JPEG (#1426).
|
||||
let grey = !img.color().has_color();
|
||||
if grey {
|
||||
let l = img.to_luma8();
|
||||
image::ImageEncoder::write_image(enc, l.as_raw(), l.width(), l.height(), image::ExtendedColorType::L8).ok()?;
|
||||
} else {
|
||||
let rgb = img.to_rgb8();
|
||||
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
|
||||
}
|
||||
jpeg(buf.into(), || embedded_icc(data).filter(|p| if grey { is_grey_icc(p) } else { is_rgb_icc(p) }), mode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+126
-4
@@ -10,6 +10,8 @@ use hayro_syntax::page::Resources;
|
||||
|
||||
use crate::transitions::{find, int_after, object, rfind};
|
||||
|
||||
mod fonts;
|
||||
|
||||
/// Escape a PDF literal string.
|
||||
fn lit(s: &str) -> String {
|
||||
let mut o = String::from("(");
|
||||
@@ -94,7 +96,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;
|
||||
@@ -246,6 +248,7 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
|
||||
issues.push("Type 3 fonts aren't allowed".into());
|
||||
}
|
||||
issues.extend(device_rgb(pdf));
|
||||
issues.extend(fonts::unembedded_fonts(pdf));
|
||||
issues
|
||||
}
|
||||
|
||||
@@ -480,7 +483,7 @@ mod tests {
|
||||
|
||||
use crate::{ExportReport, PdfOptions, Standard, check_pdfx4, export_pdf_with_report};
|
||||
|
||||
fn place(d: &mut Document, name: &str, data: Vec<u8>, size: (f64, f64), at: (f64, f64)) {
|
||||
pub(super) fn place(d: &mut Document, name: &str, data: Vec<u8>, size: (f64, f64), at: (f64, f64)) {
|
||||
let asset = AssetId(d.alloc());
|
||||
let mime = designcraft_images::mime(&data).into();
|
||||
let pixels = designcraft_images::pixel_size(&data);
|
||||
@@ -579,7 +582,7 @@ mod tests {
|
||||
pdf.finish().unwrap()
|
||||
}
|
||||
|
||||
fn x4(d: &Document) -> ExportReport {
|
||||
pub(super) fn x4(d: &Document) -> ExportReport {
|
||||
export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
@@ -645,13 +648,18 @@ mod tests {
|
||||
/// A minimal PDF with a CMYK (N 4) PDF/X output intent and one page with `entries` (its
|
||||
/// resources, a group…) and `content`.
|
||||
fn tiny_pdf(entries: &str, content: &str) -> Vec<u8> {
|
||||
tiny_pdf_with(entries, content, "<</N 3/Length 0>>\nstream\n\nendstream")
|
||||
}
|
||||
|
||||
/// [`tiny_pdf`] with `extra` as object 6 (an RGB profile in [`tiny_pdf`]).
|
||||
pub(super) fn tiny_pdf_with(entries: &str, content: &str, extra: &str) -> Vec<u8> {
|
||||
let objects = [
|
||||
"<</Type/Catalog/Pages 2 0 R/OutputIntents[<</Type/OutputIntent/S/GTS_PDFX/DestOutputProfile 4 0 R>>]>>".to_string(),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
|
||||
format!("<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]{entries}/Contents 5 0 R>>"),
|
||||
"<</N 4/Length 0>>\nstream\n\nendstream".to_string(),
|
||||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||||
"<</N 3/Length 0>>\nstream\n\nendstream".to_string(),
|
||||
extra.to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.6\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
@@ -668,6 +676,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() {
|
||||
@@ -682,6 +702,108 @@ mod tests {
|
||||
assert!(!cmyk.iter().any(rgb), "{cmyk:?}");
|
||||
}
|
||||
|
||||
/// Every font that draws visible text must be embedded (#909): a base-14 font with no font
|
||||
/// file, a CID font whose descendant has none, and one drawn from inside a form are reported;
|
||||
/// an embedded font, an unused one and invisible text (render mode 3, as in OCR layers) are not.
|
||||
#[test]
|
||||
fn unembedded_fonts_are_flagged() {
|
||||
let fonts = |entries: &str, content: &str| -> Vec<String> {
|
||||
check_pdfx4(&tiny_pdf(entries, content)).into_iter().filter(|i| i.contains("font")).collect()
|
||||
};
|
||||
let helvetica = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>";
|
||||
assert_eq!(
|
||||
fonts(helvetica, "BT /F1 24 Tf 20 50 Td (Base14 text) Tj ET"),
|
||||
["the font Helvetica isn't embedded (page 1): PDF/X-4 needs every font used for visible text embedded"]
|
||||
);
|
||||
assert!(fonts(helvetica, "BT /F1 24 Tf 3 Tr 20 50 Td (OCR) Tj ET").is_empty(), "invisible text needs no embedded font");
|
||||
assert!(fonts(helvetica, "0 0 0 1 k 0 0 10 10 re f").is_empty(), "an unused font is not reported");
|
||||
let restored = fonts(helvetica, "BT /F1 24 Tf q 3 Tr Q 20 50 Td (shown) Tj ET");
|
||||
assert_eq!(restored.len(), 1, "Q restores the visible render mode: {restored:?}");
|
||||
|
||||
let embedded = "/Resources<</Font<</F1<</Type/Font/Subtype/TrueType/BaseFont/ABCDEF+Arial/FontDescriptor<</Type/FontDescriptor/FontName/ABCDEF+Arial/FontFile2 6 0 R>>>>>>>>";
|
||||
assert!(fonts(embedded, "BT /F1 12 Tf (x) Tj ET").is_empty());
|
||||
let cid = "/Resources<</Font<</F1<</Type/Font/Subtype/Type0/BaseFont/KozMin/Encoding/Identity-H/DescendantFonts[<</Type/Font/Subtype/CIDFontType0/BaseFont/KozMin/FontDescriptor<</Type/FontDescriptor/FontName/KozMin>>>>]>>>>>>";
|
||||
assert_eq!(fonts(cid, "BT /F1 12 Tf <0001> Tj ET").len(), 1);
|
||||
let cid_embedded = cid.replace("/FontName/KozMin>>", "/FontName/KozMin/FontFile3 6 0 R>>");
|
||||
assert!(fonts(&cid_embedded, "BT /F1 12 Tf <0001> Tj ET").is_empty());
|
||||
|
||||
let draw = "BT /T 9 Tf (in a form) Tj ET";
|
||||
let form = format!(
|
||||
"<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</T<</Type/Font/Subtype/Type1/BaseFont/Times-Roman>>>>>>/Length {}>>\nstream\n{draw}\nendstream",
|
||||
draw.len()
|
||||
);
|
||||
let pdf = tiny_pdf_with("/Resources<</XObject<</X1 6 0 R>>>>", "/X1 Do", &form);
|
||||
let issues: Vec<String> = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("font")).collect();
|
||||
assert_eq!(issues.len(), 1, "{issues:?}");
|
||||
assert!(issues[0].starts_with("the font Times-Roman isn't embedded"), "{issues:?}");
|
||||
}
|
||||
|
||||
/// A placed PDF that draws with an unembedded font: the PDF/X-4 export warns about it (#909).
|
||||
#[test]
|
||||
fn placed_pdf_with_an_unembedded_font_is_reported() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let placed = tiny_pdf(
|
||||
"/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>>>>>",
|
||||
"BT /F1 24 Tf 0 0 0 1 k 20 50 Td (Base14 text) Tj ET",
|
||||
);
|
||||
place(&mut d, "helvetica.pdf", placed, (100.0, 100.0), (72.0, 72.0));
|
||||
let r = x4(&d);
|
||||
assert!(r.warnings.iter().any(|w| w.starts_with("PDF/X-4: the font Helvetica isn't embedded (page 1)")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unembedded_fonts_are_reported_by_every_text_show_operator() {
|
||||
let entries = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>";
|
||||
for show in ["(x) Tj", "[(x) 20 (y)] TJ", "(x) '", "0 0 (x) \""] {
|
||||
let pdf = tiny_pdf(entries, &format!("BT /F1 12 Tf {show} ET"));
|
||||
let issues = check_pdfx4(&pdf);
|
||||
assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{show}: {issues:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unembedded_fonts_in_forms_inherit_and_restore_text_state() {
|
||||
let entries = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>/XObject<</X1 6 0 R>>>>";
|
||||
let draw = "BT (inherited font) Tj /F1 10 Tf 3 Tr ET";
|
||||
let form = format!("<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Length {}>>\nstream\n{draw}\nendstream", draw.len());
|
||||
for (content, count) in [
|
||||
("/F1 12 Tf /X1 Do", 1),
|
||||
("/F1 12 Tf 3 Tr /X1 Do", 0),
|
||||
("/F1 12 Tf 3 Tr /X1 Do 0 Tr /X1 Do", 1),
|
||||
("/F1 12 Tf /X1 Do BT (still visible) Tj ET", 1),
|
||||
] {
|
||||
let pdf = tiny_pdf_with(entries, content, &form);
|
||||
let issues = check_pdfx4(&pdf);
|
||||
assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), count, "{content}: {issues:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unembedded_fonts_in_recursive_forms_are_bounded() {
|
||||
let draw = "BT /F1 12 Tf (x) Tj ET /X1 Do";
|
||||
let form = format!(
|
||||
"<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>/XObject<</X1 6 0 R>>>>/Length {}>>\nstream\n{draw}\nendstream",
|
||||
draw.len()
|
||||
);
|
||||
let pdf = tiny_pdf_with("/Resources<</XObject<</X1 6 0 R>>>>", "/X1 Do", &form);
|
||||
let issues = check_pdfx4(&pdf);
|
||||
assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{issues:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unembedded_fonts_are_reported_after_form_text_state_is_restored() {
|
||||
let entries = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>/XObject<</X1 6 0 R>>>>";
|
||||
let draw = "BT /F2 10 Tf 3 Tr (hidden) Tj ET";
|
||||
let form = format!(
|
||||
"<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</F2<</Type/Font/Subtype/Type1/BaseFont/Times-Roman>>>>>>/Length {}>>\nstream\n{draw}\nendstream",
|
||||
draw.len()
|
||||
);
|
||||
let pdf = tiny_pdf_with(entries, "/F1 12 Tf /X1 Do BT (visible) Tj ET", &form);
|
||||
let issues: Vec<String> = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("isn't embedded")).collect();
|
||||
assert_eq!(issues.len(), 1, "{issues:?}");
|
||||
assert!(issues[0].starts_with("the font Helvetica isn't embedded"), "{issues:?}");
|
||||
}
|
||||
|
||||
/// An RGB blending space on the page group and an RGB inline image are device RGB too.
|
||||
#[test]
|
||||
fn rgb_groups_and_inline_images_are_flagged() {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use hayro_syntax::content::UntypedIter;
|
||||
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier};
|
||||
use hayro_syntax::page::Resources;
|
||||
|
||||
use super::MAX_DEPTH;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
const MAX_OPERATIONS: usize = 100_000;
|
||||
const LIMIT_WARNING: &str = "font scan operation limit reached: PDF/X-4 font embedding checks are incomplete";
|
||||
const DEPTH_WARNING: &str = "font scan nesting depth limit reached: PDF/X-4 font embedding checks are incomplete";
|
||||
|
||||
pub(super) fn unembedded_fonts(pdf: &[u8]) -> Vec<String> {
|
||||
let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { return Vec::new() };
|
||||
let mut found: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
let mut scan = FontScan::default();
|
||||
for (i, page) in file.pages().iter().enumerate() {
|
||||
scan.font = None;
|
||||
scan.mode = 0;
|
||||
if let Some(content) = page.page_stream() {
|
||||
scan.content(content, page.resources(), 0);
|
||||
}
|
||||
for name in std::mem::take(&mut scan.found) {
|
||||
found.entry(name).or_default().push(i + 1);
|
||||
}
|
||||
if scan.limited {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut warnings: Vec<String> = found
|
||||
.into_iter()
|
||||
.map(|(name, pages)| {
|
||||
let list: Vec<String> = pages.iter().take(10).map(|p| p.to_string()).collect();
|
||||
let more = if pages.len() > 10 { ", …" } else { "" };
|
||||
let s = if pages.len() == 1 { "" } else { "s" };
|
||||
format!("the font {name} isn't embedded (page{s} {}{more}): PDF/X-4 needs every font used for visible text embedded", list.join(", "))
|
||||
})
|
||||
.collect();
|
||||
if scan.limited {
|
||||
warnings.push(LIMIT_WARNING.to_string());
|
||||
}
|
||||
if scan.depth_limited {
|
||||
warnings.push(DEPTH_WARNING.to_string());
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
fn font_embedded(font: &Dict<'_>) -> bool {
|
||||
let descriptor = match font.get::<Name<'_>>(b"Subtype").as_deref() {
|
||||
Some(b"Type3") => return true,
|
||||
Some(b"Type0") => {
|
||||
font.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next()).and_then(|d| d.get::<Dict<'_>>(b"FontDescriptor"))
|
||||
}
|
||||
_ => font.get::<Dict<'_>>(b"FontDescriptor"),
|
||||
};
|
||||
descriptor.is_some_and(|d| d.contains_key(b"FontFile") || d.contains_key(b"FontFile2") || d.contains_key(b"FontFile3"))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FontScan<'a> {
|
||||
found: BTreeSet<String>,
|
||||
active: HashSet<ObjectIdentifier>,
|
||||
path: Vec<ObjectIdentifier>,
|
||||
completed: HashMap<FormContext<'a>, BTreeSet<String>>,
|
||||
font: Option<Dict<'a>>,
|
||||
mode: i32,
|
||||
operations: usize,
|
||||
limited: bool,
|
||||
// A form nested too deeply to follow. The scan goes on (unlike `limited`), and the flag is
|
||||
// never cleared, so a memoized subtree that was cut short can't hide it.
|
||||
depth_limited: bool,
|
||||
}
|
||||
|
||||
// Forms inherit text state and missing resource names. Depth and ancestors also affect
|
||||
// which descendants the depth/cycle guards permit; only identical contexts may share results.
|
||||
#[derive(Hash, PartialEq, Eq)]
|
||||
struct FormContext<'a> {
|
||||
form: ObjectIdentifier,
|
||||
font: Option<&'a [u8]>,
|
||||
mode: i32,
|
||||
resources: Vec<(&'a [u8], &'a [u8])>,
|
||||
ancestors: Vec<ObjectIdentifier>,
|
||||
}
|
||||
|
||||
impl<'a> FontScan<'a> {
|
||||
fn content(&mut self, data: &[u8], res: &Resources<'a>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let mut saved = Vec::new();
|
||||
let mut ops = UntypedIter::new(data);
|
||||
while let Some(op) = ops.next() {
|
||||
if self.operations == MAX_OPERATIONS {
|
||||
self.limited = true;
|
||||
return;
|
||||
}
|
||||
self.operations += 1;
|
||||
let operator: &[u8] = op.operator;
|
||||
match operator {
|
||||
b"q" => saved.push((self.font.clone(), self.mode)),
|
||||
b"Q" => {
|
||||
if let Some(state) = saved.pop() {
|
||||
(self.font, self.mode) = state;
|
||||
}
|
||||
}
|
||||
b"Tf" => self.font = op.operands().next().cloned().and_then(Object::into_name).and_then(|n| res.get_font(&n)),
|
||||
b"Tr" => {
|
||||
if let Some(mode) = op.operands().next().cloned().and_then(|n| i32::try_from(n).ok()) {
|
||||
self.mode = mode;
|
||||
}
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" if self.mode != 3 => {
|
||||
if let Some(font) = self.font.as_ref().filter(|f| !font_embedded(f)) {
|
||||
let name = font.get::<Name<'_>>(b"BaseFont").map_or_else(|| "(unnamed)".to_string(), |n| n.as_str().to_string());
|
||||
self.found.insert(name);
|
||||
}
|
||||
}
|
||||
b"Do" => {
|
||||
let xo = op.operands().last().cloned().and_then(Object::into_name).and_then(|n| res.get_x_object(&n));
|
||||
if let Some(form) = xo.filter(|x| x.dict().get::<Name<'_>>(b"Subtype").as_deref() == Some(b"Form"))
|
||||
&& (depth < MAX_DEPTH || {
|
||||
// A form already being scanned is a cycle: it can't hide new content.
|
||||
self.depth_limited |= !self.active.contains(&form.obj_id());
|
||||
false
|
||||
})
|
||||
&& self.active.insert(form.obj_id())
|
||||
{
|
||||
let mut resources = Vec::new();
|
||||
let mut parent = Some(res);
|
||||
while let Some(r) = parent {
|
||||
resources.push((r.fonts.data(), r.x_objects.data()));
|
||||
parent = r.parent();
|
||||
}
|
||||
let key = FormContext {
|
||||
form: form.obj_id(),
|
||||
font: self.font.as_ref().map(Dict::data),
|
||||
mode: self.mode,
|
||||
resources,
|
||||
ancestors: self.path.clone(),
|
||||
};
|
||||
if let Some(found) = self.completed.get(&key) {
|
||||
self.found.extend(found.iter().cloned());
|
||||
self.active.remove(&form.obj_id());
|
||||
continue;
|
||||
}
|
||||
let inner = Resources::from_parent(form.dict().get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
|
||||
let state = (self.font.clone(), self.mode);
|
||||
let outer_found = std::mem::take(&mut self.found);
|
||||
self.path.push(form.obj_id());
|
||||
if let Ok(data) = form.decoded() {
|
||||
self.content(&data, &inner, depth + 1);
|
||||
}
|
||||
self.path.pop();
|
||||
if !self.limited {
|
||||
self.completed.insert(key, self.found.clone());
|
||||
}
|
||||
self.found.extend(outer_found);
|
||||
(self.font, self.mode) = state;
|
||||
self.active.remove(&form.obj_id());
|
||||
if self.limited {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{DEPTH_WARNING, FontScan, LIMIT_WARNING, MAX_OPERATIONS, unembedded_fonts};
|
||||
use crate::check_pdfx4;
|
||||
use crate::pdfx::tests::{place, tiny_pdf_with, x4};
|
||||
use designcraft_doc::{Document, build::NewDocument};
|
||||
|
||||
fn stream(entries: &str, content: &str) -> String {
|
||||
format!("<<{entries}/Length {}>>\nstream\n{content}\nendstream", content.len())
|
||||
}
|
||||
|
||||
fn fanout_pdf(levels: usize) -> Vec<u8> {
|
||||
let mut objects = vec![
|
||||
"<</Type/Catalog/Pages 2 0 R>>".to_string(),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</XObject<</Next 5 0 R>>>>/Contents 4 0 R>>".to_string(),
|
||||
stream("", "/Next Do"),
|
||||
];
|
||||
for level in 0..levels {
|
||||
let resources = if level + 1 == levels {
|
||||
"/Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>".to_string()
|
||||
} else {
|
||||
format!("/XObject<</Next {} 0 R>>", level + 6)
|
||||
};
|
||||
let content = if level + 1 == levels { "BT /F1 12 Tf (bottom) Tj ET".to_string() } else { "/Next Do\n".repeat(10) };
|
||||
objects.push(stream(&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<<{resources}>>"), &content));
|
||||
}
|
||||
pdf_objects(&objects)
|
||||
}
|
||||
|
||||
fn pdf_objects(objects: &[String]) -> Vec<u8> {
|
||||
let mut bytes = b"%PDF-1.6\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_form_fanout_is_memoized_and_reports_bottom_font() {
|
||||
let bytes = fanout_pdf(16);
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = pdf.pages().first().unwrap();
|
||||
let mut scan = FontScan::default();
|
||||
let start = Instant::now();
|
||||
scan.content(page.page_stream().unwrap(), page.resources(), 0);
|
||||
assert!(start.elapsed() < Duration::from_millis(500));
|
||||
assert_eq!(scan.operations, 155);
|
||||
assert_eq!(scan.completed.len(), 16);
|
||||
assert!(!scan.limited);
|
||||
assert!(!scan.depth_limited);
|
||||
assert_eq!(scan.found.into_iter().collect::<Vec<_>>(), ["Helvetica"]);
|
||||
assert!(check_pdfx4(&bytes).iter().any(|w| w.contains("Helvetica isn't embedded")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_pdf_nested_form_fanout_reports_unembedded_font() {
|
||||
let mut doc = Document::new(&NewDocument::default());
|
||||
place(&mut doc, "fanout.pdf", fanout_pdf(14), (100.0, 100.0), (72.0, 72.0));
|
||||
let report = x4(&doc);
|
||||
assert!(report.warnings.iter().any(|w| w.contains("Helvetica isn't embedded")), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_memo_resolves_resource_names_in_each_parent_context() {
|
||||
let wrapper = |font: &str| {
|
||||
stream(
|
||||
&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/{font}>>>>>>"),
|
||||
"/Shared Do",
|
||||
)
|
||||
};
|
||||
let bytes = pdf_objects(&[
|
||||
"<</Type/Catalog/Pages 2 0 R>>".into(),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</XObject<</A 5 0 R/B 6 0 R/Shared 7 0 R>>>>/Contents 4 0 R>>".into(),
|
||||
stream("", "/A Do /B Do"),
|
||||
wrapper("Helvetica"),
|
||||
wrapper("Times-Roman"),
|
||||
stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]", "BT /F1 12 Tf (shared) Tj ET"),
|
||||
]);
|
||||
let issues = unembedded_fonts(&bytes);
|
||||
assert_eq!(issues.len(), 2, "{issues:?}");
|
||||
assert!(issues.iter().any(|w| w.contains("Helvetica isn't embedded")));
|
||||
assert!(issues.iter().any(|w| w.contains("Times-Roman isn't embedded")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_memo_replays_font_findings_on_each_page() {
|
||||
let page = "<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</XObject<</Shared 6 0 R>>>>/Contents 5 0 R>>";
|
||||
let bytes = pdf_objects(&[
|
||||
"<</Type/Catalog/Pages 2 0 R>>".into(),
|
||||
"<</Type/Pages/Kids[3 0 R 4 0 R]/Count 2>>".into(),
|
||||
page.into(),
|
||||
page.into(),
|
||||
stream("", "/Shared Do"),
|
||||
stream(
|
||||
"/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>",
|
||||
"BT /F1 12 Tf (shared) Tj ET",
|
||||
),
|
||||
]);
|
||||
let issues = unembedded_fonts(&bytes);
|
||||
assert_eq!(issues.len(), 1, "{issues:?}");
|
||||
assert!(issues[0].contains("(pages 1, 2)"), "{issues:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_memo_keeps_inherited_font_and_resource_contexts_distinct() {
|
||||
let draw = "BT (inherited) Tj /F1 12 Tf (resource) Tj ET";
|
||||
let form = stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]", draw);
|
||||
let entries = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>/F2<</Type/Font/Subtype/Type1/BaseFont/Times-Roman>>>>/XObject<</X1 6 0 R>>>>";
|
||||
let bytes = tiny_pdf_with(entries, "/F1 12 Tf /X1 Do /F2 12 Tf /X1 Do", &form);
|
||||
let issues = unembedded_fonts(&bytes);
|
||||
assert_eq!(issues.len(), 2, "{issues:?}");
|
||||
assert!(issues.iter().any(|w| w.contains("Times-Roman isn't embedded")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_scan_operation_budget_warns_instead_of_silently_truncating() {
|
||||
let content = "q Q\n".repeat(MAX_OPERATIONS);
|
||||
let bytes = tiny_pdf_with("/Resources<<>>", &content, "<<>>");
|
||||
assert_eq!(unembedded_fonts(&bytes), [LIMIT_WARNING]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_scan_depth_limit_warns_instead_of_silently_truncating() {
|
||||
// The font is two levels below the deepest form followed, and every level draws the next
|
||||
// ten times: memo replays of the cut-short subtree still give one warning.
|
||||
assert_eq!(unembedded_fonts(&fanout_pdf(18)), [DEPTH_WARNING]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_scan_cycles_are_not_reported_as_incomplete() {
|
||||
let font = "/Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>";
|
||||
let bytes = pdf_objects(&[
|
||||
"<</Type/Catalog/Pages 2 0 R>>".to_string(),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</XObject<</A 5 0 R>>>>/Contents 4 0 R>>".to_string(),
|
||||
stream("", "/A Do"),
|
||||
stream(&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<<{font}/XObject<</B 6 0 R>>>>"), "BT /F1 12 Tf (a) Tj ET /B Do"),
|
||||
stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</XObject<</A 5 0 R/B 6 0 R>>>>", "/A Do /B Do"),
|
||||
]);
|
||||
let warnings = unembedded_fonts(&bytes);
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
assert!(warnings[0].contains("Helvetica isn't embedded"), "{warnings:?}");
|
||||
}
|
||||
@@ -340,6 +340,26 @@ fn cmyk_tiff_keeps_its_inks() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed greyscale TIFF went into PDFs as DeviceRGB, unlike a greyscale PNG or JPEG (#1426):
|
||||
/// a page holding only a black-and-white scan declared colour.
|
||||
#[test]
|
||||
fn grey_tiff_exports_as_device_gray() {
|
||||
let px: Vec<u8> = vec![0, 64, 128, 255, 255, 128, 64, 0];
|
||||
let mut tif = Vec::new();
|
||||
tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut tif)).unwrap().write_image::<tiff::encoder::colortype::Gray8>(4, 2, &px).unwrap();
|
||||
let mut d = doc_with_text("x");
|
||||
place(&mut d, tif, (4, 2));
|
||||
for standard in [Standard::None, Standard::PdfX4] {
|
||||
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings);
|
||||
assert_eq!(image_xobjects(&r.bytes), vec![("DeviceGray".to_string(), px.clone())], "{standard:?}");
|
||||
}
|
||||
// Recompressed, it stays a grey JPEG.
|
||||
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images: true, compress: false, ..Default::default() }).unwrap();
|
||||
let s = String::from_utf8_lossy(&r.bytes);
|
||||
assert!(s.contains("/DCTDecode") && s.contains("/DeviceGray") && !s.contains("/DeviceRGB"), "a grey JPEG");
|
||||
}
|
||||
|
||||
/// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner.
|
||||
fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec<u8> {
|
||||
let img =
|
||||
|
||||
+37
-1
@@ -160,7 +160,7 @@ fn cluster_chars(members: &[PlacedGlyph], cluster: &str) -> Option<Vec<(usize, c
|
||||
fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> Vec<(designcraft_compose::Rule, Rect)> {
|
||||
let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
|
||||
for line in &ft.lines {
|
||||
for g in line.glyphs.iter().filter(|g| g.visible) {
|
||||
for g in line.glyphs.iter().filter(|g| g.spans_rules()) {
|
||||
let style = &cs.styles[g.style as usize];
|
||||
let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) };
|
||||
if !on {
|
||||
@@ -638,3 +638,39 @@ fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64, tan: f64) -> BezPath {
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rule_tests {
|
||||
use super::*;
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::{Document, ParaFormat, SpreadRef};
|
||||
use designcraft_geom::Rect;
|
||||
|
||||
/// A tab draws nothing but advances to its stop, and the underline runs across it, as it does
|
||||
/// on screen: a form's fill-in lines are underlined tabs (#1466).
|
||||
#[test]
|
||||
fn an_underline_runs_across_a_tab() {
|
||||
let mut doc = Document::new(&Default::default());
|
||||
for style in &mut doc.styles_mut().paragraph {
|
||||
style.chars.underline = Some(true);
|
||||
}
|
||||
let layer = doc.default_layer();
|
||||
let (_, sid) =
|
||||
doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 300.0), layer, "Name\tCity", ParaFormat::default()).unwrap();
|
||||
let cs = Cache::new().get(&doc, sid, None);
|
||||
let line = &cs.frames[0].lines[0];
|
||||
let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line");
|
||||
let bars = text_rules(&cs, &cs.frames[0], true);
|
||||
assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}");
|
||||
let bar = bars[0].1;
|
||||
assert!(bar.x0 <= tab.x + 0.5 && bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab: {bar:?}, tab {} + {}", tab.x, tab.adv);
|
||||
// The strikethrough of the same text behaves the same way.
|
||||
for style in &mut doc.styles_mut().paragraph {
|
||||
style.chars.underline = None;
|
||||
style.chars.strikethrough = Some(true);
|
||||
}
|
||||
let cs = Cache::new().get(&doc, sid, None);
|
||||
assert!(text_rules(&cs, &cs.frames[0], true).is_empty(), "no underline now");
|
||||
assert_eq!(text_rules(&cs, &cs.frames[0], false).len(), 1, "one strike across the line");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -162,7 +162,7 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
|
||||
let mut strikes: Vec<(String, f32, Rect)> = Vec::new();
|
||||
let mut glyphs = 0;
|
||||
for g in &l.glyphs {
|
||||
if !g.visible {
|
||||
if !g.spans_rules() {
|
||||
continue;
|
||||
}
|
||||
let style = &cs.styles[g.style as usize];
|
||||
@@ -185,6 +185,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
|
||||
}
|
||||
}
|
||||
}
|
||||
// A tab carries the rule across its width but draws nothing itself.
|
||||
if !g.visible {
|
||||
continue;
|
||||
}
|
||||
let outline = db.outline(&g.face, g.gid);
|
||||
if outline.elements().is_empty() {
|
||||
continue;
|
||||
@@ -725,3 +729,45 @@ mod cache_identity_tests {
|
||||
assert_eq!(glyphs.lines[0].runs, runs);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rule_tests {
|
||||
use super::*;
|
||||
|
||||
/// A tab is not drawn, but it is a character with an advance, and InDesign underlines it:
|
||||
/// forms rule their fill-in lines with underlined tabs (`Name ⇥⇥⇥⇥, city ⇥⇥⇥⇥`). The bars
|
||||
/// stopped at the last letter before each tab, so an imported form lost every writing line
|
||||
/// (#1466).
|
||||
#[test]
|
||||
fn an_underline_runs_across_a_tab() {
|
||||
let mut doc = designcraft_doc::Document::new(&Default::default());
|
||||
for style in &mut doc.styles_mut().paragraph {
|
||||
style.chars.underline = Some(true);
|
||||
}
|
||||
let layer = doc.default_layer();
|
||||
let (_, sid) = doc
|
||||
.add_text_frame(
|
||||
designcraft_doc::SpreadRef::Doc(0),
|
||||
Rect::new(36.0, 36.0, 400.0, 300.0),
|
||||
layer,
|
||||
"Name\tCity",
|
||||
designcraft_doc::ParaFormat::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let owner = crate::Cache::new();
|
||||
let cs = owner.get(&doc, sid, None);
|
||||
let line = &cs.frames[0].lines[0];
|
||||
let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line");
|
||||
assert!(!tab.visible, "a tab draws nothing itself");
|
||||
assert!(tab.adv > 1.0, "it advances to its stop: {}", tab.adv);
|
||||
|
||||
let mut cache = GlyphCache::default();
|
||||
cache.tick();
|
||||
let glyphs = cache.get(&cs, &cs.frames[0]);
|
||||
let bars = &glyphs.lines[0].underlines;
|
||||
assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}");
|
||||
let bar = bars[0].2;
|
||||
assert!(bar.x0 <= tab.x + 0.5, "the bar starts at the text, not at the tab: {bar:?}");
|
||||
assert!(bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab's width: {bar:?}, tab {} + {}", tab.x, tab.adv);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,12 @@ pub(crate) fn parse(xml: &str) -> Result<El, ImportError> {
|
||||
p.kids.push(Node::Text(s));
|
||||
}
|
||||
}
|
||||
Event::CData(t) => {
|
||||
let s = t.decode().map(|s| s.to_string()).unwrap_or_default();
|
||||
if let Some(p) = stack.last_mut() {
|
||||
p.kids.push(Node::Text(s));
|
||||
}
|
||||
}
|
||||
Event::GeneralRef(e) => {
|
||||
let name = String::from_utf8_lossy(&e).to_string();
|
||||
let s = match name.as_str() {
|
||||
@@ -180,8 +186,9 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs {
|
||||
if let Some(s) = r.on("strike") {
|
||||
a.strikethrough = Some(s);
|
||||
}
|
||||
if let Some(sz) = r.val("sz").and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite()) {
|
||||
a.size = Some(sz / 2.0);
|
||||
// Half-points; sizes outside the engine's 0.1–1296 pt range (or non-finite) are dropped.
|
||||
if let Some(pt) = r.val("sz").and_then(|v| v.parse::<f64>().ok()).map(|sz| sz / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)) {
|
||||
a.size = Some(pt);
|
||||
}
|
||||
if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) {
|
||||
a.font_family = Some(f.to_string());
|
||||
@@ -578,6 +585,12 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn cdata_text() {
|
||||
let el = parse("<a><t><![CDATA[x < y]]></t></a>").unwrap();
|
||||
assert_eq!(el.child("t").unwrap().text(), "x < y");
|
||||
}
|
||||
|
||||
/// Issue #712: nesting past the budget is an error from the parser, not a stack overflow later.
|
||||
#[test]
|
||||
fn nesting_past_the_budget_is_refused() {
|
||||
@@ -702,6 +715,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_sizes_are_dropped() {
|
||||
let document = format!(
|
||||
r#"<w:document {W}><w:body>
|
||||
<w:p><w:r><w:rPr><w:sz w:val="0"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:sz w:val="99999999"/></w:rPr><w:t>b</w:t></w:r></w:p>
|
||||
</w:body></w:document>"#
|
||||
);
|
||||
let st = import(&docx(&document, "", "")).unwrap().story;
|
||||
for at in [0, 1] {
|
||||
assert_eq!(st.format_after(at).over.size, None, "run {at}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_changes_import_as_accepted_including_moves() {
|
||||
// Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -25,6 +25,37 @@ fn escape(out: &mut String, c: char) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tag value with '<', '>' and '\\' escaped by a backslash, so it keeps the tag intact.
|
||||
fn escape_value(v: &str) -> String {
|
||||
let mut out = String::with_capacity(v.len());
|
||||
for c in v.chars() {
|
||||
if matches!(c, '<' | '>' | '\\') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A tag value with the escapes `escape_value` writes (`\\`, `\<`, `\>`) decoded. Any other
|
||||
/// backslash, including a trailing one, stays literal, as older files and InDesign's `\:` expect.
|
||||
fn unescape_value(v: &str) -> String {
|
||||
let mut out = String::with_capacity(v.len());
|
||||
let mut it = v.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
if c == '\\'
|
||||
&& let Some(&n) = it.peek()
|
||||
&& matches!(n, '\\' | '<' | '>')
|
||||
{
|
||||
out.push(n);
|
||||
it.next();
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `story` as Tagged Text.
|
||||
pub fn tagged_text(doc: &Document, story: &Story) -> String {
|
||||
let mut out = String::from("<ASCII-WIN>\r\n<Version:7>\r\n");
|
||||
@@ -34,7 +65,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
let pf = story.paras.get(i).cloned().unwrap_or_default();
|
||||
let _ = write!(out, "<ParaStyle:{}>", pf.style.replace(['<', '>', ':'], ""));
|
||||
let _ = write!(out, "<ParaStyle:{}>", escape_value(&pf.style));
|
||||
for (rr, f) in story.runs() {
|
||||
let (a, b) = (rr.start.max(r.start), rr.end.min(r.end));
|
||||
if a >= b {
|
||||
@@ -45,7 +76,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
|
||||
}
|
||||
let mut open = Vec::new();
|
||||
if f.style != NO_CHAR_STYLE {
|
||||
let _ = write!(out, "<CharStyle:{}>", f.style.replace(['<', '>', ':'], ""));
|
||||
let _ = write!(out, "<CharStyle:{}>", escape_value(&f.style));
|
||||
open.push("CharStyle");
|
||||
}
|
||||
if let Some(v) = &f.over.font_family {
|
||||
@@ -72,7 +103,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..]
|
||||
@@ -153,7 +184,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
}
|
||||
"ParaStyle" => {
|
||||
started = true;
|
||||
let v = value.trim().to_string();
|
||||
let v = unescape_value(value.trim());
|
||||
if !v.is_empty() && !para_styles.contains(&v) {
|
||||
para_styles.push(v.clone());
|
||||
}
|
||||
@@ -162,10 +193,10 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
}
|
||||
}
|
||||
"CharStyle" => {
|
||||
let v = value.trim();
|
||||
fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.to_string() };
|
||||
if !v.is_empty() && !char_styles.contains(&v.to_string()) {
|
||||
char_styles.push(v.to_string());
|
||||
let v = unescape_value(value.trim());
|
||||
fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() };
|
||||
if !v.is_empty() && !char_styles.contains(&v) {
|
||||
char_styles.push(v);
|
||||
}
|
||||
}
|
||||
"cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()),
|
||||
@@ -268,6 +299,34 @@ mod tests {
|
||||
assert_eq!(back.story.paras, story.paras);
|
||||
}
|
||||
|
||||
/// Style names with ':', '<', '>' or '\' were written with those characters dropped, so
|
||||
/// re-import gave the paragraphs a different style.
|
||||
#[test]
|
||||
fn style_names_with_tag_characters_round_trip() {
|
||||
let doc = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
for name in ["Body: Indent", "A<b>c", "x\\y", "end\\"] {
|
||||
let mut story = Story::new(StoryId(0));
|
||||
story.insert_with(0, "Text", CharFormat { style: name.into(), ..Default::default() });
|
||||
story.paras[0].style = name.into();
|
||||
let out = tagged_text(&doc, &story);
|
||||
let back = import(out.as_bytes()).unwrap();
|
||||
assert_eq!(back.story.text, "Text", "{out}");
|
||||
assert_eq!(back.story.paras[0].style, name, "{out}");
|
||||
assert_eq!(back.story.char_format_at(1).style, name, "{out}");
|
||||
assert_eq!(back.para_styles.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), [name]);
|
||||
assert_eq!(back.char_styles.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), [name]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A backslash that isn't one of our escapes (older files, InDesign's `\:`) stays literal.
|
||||
#[test]
|
||||
fn unknown_backslashes_in_style_names_stay_literal() {
|
||||
assert_eq!(unescape_value(r"x\y"), r"x\y");
|
||||
assert_eq!(unescape_value(r"a\:b"), r"a\:b");
|
||||
assert_eq!(unescape_value("end\\"), "end\\");
|
||||
assert_eq!(unescape_value(r"\<a\>\\"), r"<a>\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_text_round_trips() {
|
||||
let src = "<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Head>Caf<0x00E9> \\<1\\>\r\n<ParaStyle:Body>A <CharStyle:Em>word<CharStyle:> and <cSize:18.000000>big<cSize:> text";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Selection tool (V) and Direct Selection tool (A).
|
||||
//!
|
||||
//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains),
|
||||
//! Click selects the frontmost item (Shift toggles; a click on one of several selected items makes
|
||||
//! it the key object for Align, a second click drops it), drag moves (Alt duplicates, Shift constrains),
|
||||
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
|
||||
//! empty-canvas drags make a marquee (it selects the ruler guides it crosses when it touches no
|
||||
//! object), double-clicking a text frame switches to the Type tool and
|
||||
@@ -223,6 +224,9 @@ pub struct SelectionTool {
|
||||
guides: Vec<Overlay>,
|
||||
/// Selected item to toggle if this Shift press never becomes a drag.
|
||||
shift_release: Option<u64>,
|
||||
/// One of several selected items to make (or stop being) the key object for Align if this
|
||||
/// press never becomes a drag.
|
||||
key_release: Option<u64>,
|
||||
/// The loaded text cursor.
|
||||
loaded: Option<Loaded>,
|
||||
/// Frame whose live corners are being edited (diamonds shown).
|
||||
@@ -231,7 +235,7 @@ pub struct SelectionTool {
|
||||
|
||||
impl SelectionTool {
|
||||
pub fn new(direct: bool) -> Self {
|
||||
Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, loaded: None, live: None }
|
||||
Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, loaded: None, live: None }
|
||||
}
|
||||
|
||||
/// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the
|
||||
@@ -735,6 +739,7 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
PointerKind::Down => {
|
||||
self.shift_release = None;
|
||||
self.key_release = None;
|
||||
// Type on a path: a bracket moves where the text starts or ends.
|
||||
if !ev.mods.shift
|
||||
&& let Some((id, end)) = self.bracket_at(cx, p)
|
||||
@@ -825,6 +830,10 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
} else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) {
|
||||
out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct})));
|
||||
} else if !self.direct && cx.selection.items.len() > 1 {
|
||||
// InDesign: a click on one of several selected objects makes it the
|
||||
// key object, on release so that a drag still moves them all.
|
||||
self.key_release = Some(id.0);
|
||||
}
|
||||
let content = self.direct || on_selected_content(cx, p);
|
||||
self.drag = Drag::Pending { start: p, hit: true, content };
|
||||
@@ -852,6 +861,7 @@ impl Tool for SelectionTool {
|
||||
return vec![];
|
||||
}
|
||||
self.shift_release = None;
|
||||
self.key_release = None;
|
||||
if hit && !cx.selection.items.is_empty() {
|
||||
// Direct Selection on items without content moves (or duplicates) them whole.
|
||||
let content = content && cx.selection.items.iter().any(|i| cx.doc.item(*i).is_some_and(holds_content));
|
||||
@@ -1124,6 +1134,7 @@ impl Tool for SelectionTool {
|
||||
PointerKind::Up => {
|
||||
self.guides.clear();
|
||||
let release = self.shift_release.take();
|
||||
let key = self.key_release.take();
|
||||
let d = std::mem::replace(&mut self.drag, Drag::None);
|
||||
match d {
|
||||
Drag::Move { .. }
|
||||
@@ -1135,9 +1146,10 @@ impl Tool for SelectionTool {
|
||||
| Drag::Bracket { .. } => {
|
||||
vec![Action::Commit]
|
||||
}
|
||||
Drag::Pending { .. } => match release {
|
||||
Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
|
||||
None => vec![],
|
||||
Drag::Pending { .. } => match (release, key) {
|
||||
(Some(id), _) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
|
||||
(None, Some(id)) => vec![Action::Exec("selection.key".into(), json!({"id": id}))],
|
||||
(None, None) => vec![],
|
||||
},
|
||||
Drag::Marquee { start, cur } => {
|
||||
let r = Rect::from_points(start, cur);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1251,6 +1251,9 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
|
||||
if sel.items.len() == 1 && sel.text.is_none() {
|
||||
// InDesign draws a 1 pt bounding box around the selection.
|
||||
painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
|
||||
} else if sel.key == Some(*id) {
|
||||
// The key object for Align: a thicker bounding box, as in InDesign.
|
||||
painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(3.0, color), StrokeKind::Middle);
|
||||
}
|
||||
let b = xf.rect(a.transform_rect_bbox(it.bounds()));
|
||||
union = Some(union.map_or(b, |u| u.union(b)));
|
||||
@@ -5317,7 +5320,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");
|
||||
|
||||
@@ -852,85 +852,97 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
egui::Align::Min
|
||||
})),
|
||||
|ui| {
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Welcome to DesignCraft")).font(semibold(28.0)).color(t.text_strong),
|
||||
));
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page layout for print and screen — fast, open and scriptable."))
|
||||
.size(15.0)
|
||||
.color(t.text_dim),
|
||||
));
|
||||
ui.add_space(24.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, " New file ")).size(14.0).color(Color32::WHITE),
|
||||
))
|
||||
.fill(t.accent_strong)
|
||||
.corner_radius(16.0)
|
||||
.min_size(vec2(0.0, 32.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("app.newDocumentDialog", json!({}));
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open ")).size(14.0)))
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Welcome to DesignCraft")).font(semibold(28.0)).color(t.text_strong),
|
||||
));
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page layout for print and screen — fast, open and scriptable."))
|
||||
.size(15.0)
|
||||
.color(t.text_dim),
|
||||
));
|
||||
ui.add_space(24.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, " New file ")).size(14.0).color(Color32::WHITE),
|
||||
))
|
||||
.fill(t.accent_strong)
|
||||
.corner_radius(16.0)
|
||||
.min_size(vec2(0.0, 32.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("app.openDialog", json!({}));
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open sample magazine ")).size(14.0),
|
||||
))
|
||||
.corner_radius(16.0)
|
||||
.min_size(vec2(0.0, 32.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("file.newSample", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(20.0);
|
||||
community_card(app, ui);
|
||||
recent_files(app, ui);
|
||||
ui.add_space(24.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Start a new document")).font(semibold(15.0)).color(t.text_strong),
|
||||
));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) {
|
||||
let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click());
|
||||
let hov = resp.hovered();
|
||||
ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel });
|
||||
let k = (80.0 / p.width.max(p.height)) as f32;
|
||||
let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k));
|
||||
ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245));
|
||||
ui.painter().text(egui::pos2(cr.center().x, cr.max.y - 26.0), egui::Align2::CENTER_CENTER, p.name, semibold(12.0), t.text_strong);
|
||||
ui.painter().text(
|
||||
egui::pos2(cr.center().x, cr.max.y - 11.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
format!("{} × {}", designcraft_geom::format_measure(p.width, p.units), designcraft_geom::format_measure(p.height, p.units)),
|
||||
egui::FontId::proportional(10.0),
|
||||
t.text_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("file.new", json!({"preset": p.name}));
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("app.newDocumentDialog", json!({}));
|
||||
}
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open ")).size(14.0)))
|
||||
.corner_radius(16.0)
|
||||
.min_size(vec2(0.0, 32.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("app.openDialog", json!({}));
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open sample magazine ")).size(14.0),
|
||||
))
|
||||
.corner_radius(16.0)
|
||||
.min_size(vec2(0.0, 32.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("file.newSample", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(20.0);
|
||||
community_card(app, ui);
|
||||
recent_files(app, ui);
|
||||
ui.add_space(24.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Start a new document")).font(semibold(15.0)).color(t.text_strong),
|
||||
));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) {
|
||||
let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click());
|
||||
let hov = resp.hovered();
|
||||
ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel });
|
||||
let k = (80.0 / p.width.max(p.height)) as f32;
|
||||
let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k));
|
||||
ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245));
|
||||
ui.painter().text(
|
||||
egui::pos2(cr.center().x, cr.max.y - 26.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
p.name,
|
||||
semibold(12.0),
|
||||
t.text_strong,
|
||||
);
|
||||
ui.painter().text(
|
||||
egui::pos2(cr.center().x, cr.max.y - 11.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
format!(
|
||||
"{} × {}",
|
||||
designcraft_geom::format_measure(p.width, p.units),
|
||||
designcraft_geom::format_measure(p.height, p.units)
|
||||
),
|
||||
egui::FontId::proportional(10.0),
|
||||
t.text_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("file.new", json!({"preset": p.name}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1060,7 +1072,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 +1104,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.
|
||||
|
||||
@@ -1991,8 +1991,8 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
|
||||
egui::ComboBox::from_id_salt("pref_language").width(180.0).height(400.0).selected_text(crate::rtl::widget(ui, current)).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for (code, name) in crate::i18n::LANGUAGES {
|
||||
if ui.selectable_label(*code == app.ui.language, crate::rtl::widget(ui, *name)).clicked() {
|
||||
for (code, name) in crate::i18n::picker_languages() {
|
||||
if ui.selectable_label(code == app.ui.language, crate::rtl::widget(ui, name)).clicked() {
|
||||
// Applied by `show` right after this frame, which has `&mut DesignApp`.
|
||||
d.fields.insert("applyLanguage".into(), json!(code));
|
||||
}
|
||||
@@ -2456,6 +2456,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
{
|
||||
match app.session.execute("color.loadProfile", &json!({"path": path})) {
|
||||
Ok(_) => {
|
||||
// Replayed in load order at start-up, so the latest load of a path comes last.
|
||||
app.ui.color_profiles.retain(|p| p != &path);
|
||||
app.ui.color_profiles.push(path);
|
||||
let f = app.session.execute("color.settings", &json!({})).unwrap_or_default();
|
||||
d.fields.insert("profiles".into(), f["profiles"].clone());
|
||||
}
|
||||
@@ -2836,6 +2839,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
ui.horizontal(|ui| {
|
||||
if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && !d.b("new") {
|
||||
check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview"));
|
||||
app.ui.style_preview = d.b("preview");
|
||||
style_options_preview(app, &mut d);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -3275,13 +3279,29 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
}
|
||||
"pdfImport" => {
|
||||
let crop = d.s("crop");
|
||||
// One page number, 1 or more; a blank field is page 1. A range or list ("4-5", "2,5"), a
|
||||
// fraction or a negative is refused rather than placing some other page.
|
||||
let raw = if d.fields.get("page").is_none_or(Value::is_null) { String::new() } else { d.s("page") };
|
||||
let page = match raw.trim() {
|
||||
"" => 1,
|
||||
t => match t.parse::<u64>() {
|
||||
Ok(n) if n >= 1 => n,
|
||||
_ => {
|
||||
let e = "Enter a single page number; one page is placed at a time".to_string();
|
||||
let mut d = d;
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
app.ui.dialog = Some(d);
|
||||
return Err(e);
|
||||
}
|
||||
},
|
||||
};
|
||||
if d.b("async") {
|
||||
return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop });
|
||||
return app.confirm_pdf_import(page, if crop.is_empty() { "crop".into() } else { crop });
|
||||
}
|
||||
if let Some(target) = d.fields.get("target").and_then(Value::as_u64) {
|
||||
app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?;
|
||||
}
|
||||
app.place_file(json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
|
||||
app.place_file(json!({"path": d.s("path"), "pdfPage": page, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
|
||||
}
|
||||
"print" => {
|
||||
let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) };
|
||||
@@ -3337,7 +3357,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
params["title"] = json!(title);
|
||||
}
|
||||
let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") };
|
||||
match app.services.pick_save.as_mut().and_then(|f| f(&name)) {
|
||||
match app.pick_save(&name) {
|
||||
Some(path) => {
|
||||
params["path"] = json!(path);
|
||||
crate::menus::export_pdf(app, ¶ms)
|
||||
@@ -5154,7 +5174,7 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) {
|
||||
return;
|
||||
}
|
||||
let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" };
|
||||
app.ui.dialog = Some(Dialog::new(id, json!({"name": name})));
|
||||
app.ui.dialog = Some(Dialog::new(id, json!({"name": name, "preview": app.ui.style_preview})));
|
||||
}
|
||||
|
||||
/// New Paragraph Style… / New Character Style…: the style options for a style made on OK, named
|
||||
@@ -7770,6 +7790,33 @@ mod tests {
|
||||
assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_options_preview_choice_is_remembered_on_reopen() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("style.paragraph.create", json!({"name":"Tutorial","chars":{"size":10}})).unwrap();
|
||||
open_style_options(&mut app, true, "Tutorial");
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
let key = egui::Id::new("style_preview_remember_fonts");
|
||||
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
|
||||
crate::theme::install_fonts(ui.ctx(), "");
|
||||
ui.data_mut(|d| d.insert_temp(key, true));
|
||||
return;
|
||||
}
|
||||
show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Preview").click();
|
||||
h.run_steps(3);
|
||||
cancel(h.state_mut());
|
||||
open_style_options(h.state_mut(), true, "Tutorial");
|
||||
assert!(h.state().ui.dialog.as_ref().unwrap().b("preview"), "Preview stays checked on reopen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_options_preview_updates_all_uses_cancels_and_commits_once() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
+149
-118
@@ -36,6 +36,16 @@ pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("hu", "Magyar"),
|
||||
];
|
||||
|
||||
/// The interface languages in the order the Preferences picker lists them: English first (the
|
||||
/// source language), then the rest by their own name with accents folded, so Čeština sits under
|
||||
/// C. Names in other scripts follow the Latin ones in code point order: Cyrillic, Arabic,
|
||||
/// Georgian, then the CJK languages.
|
||||
pub fn picker_languages() -> Vec<(&'static str, &'static str)> {
|
||||
let mut rest: Vec<(&str, &str)> = LANGUAGES.iter().copied().filter(|(code, _)| !code.is_empty()).collect();
|
||||
rest.sort_by_cached_key(|(_, name)| name_sort_key(name));
|
||||
std::iter::once(LANGUAGES[0]).chain(rest).collect()
|
||||
}
|
||||
|
||||
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
|
||||
const TABLE: &[(&str, [&str; 5])] = &[
|
||||
// Menus.
|
||||
@@ -4451,6 +4461,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();
|
||||
@@ -4473,18 +4489,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;
|
||||
}
|
||||
@@ -4531,11 +4547,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;
|
||||
}
|
||||
@@ -4574,7 +4592,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))
|
||||
}
|
||||
@@ -4640,15 +4660,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]
|
||||
@@ -4659,13 +4673,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);
|
||||
}
|
||||
}
|
||||
@@ -4763,7 +4780,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 {
|
||||
@@ -4774,8 +4793,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.
|
||||
@@ -4823,29 +4844,22 @@ mod tests {
|
||||
/// 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();
|
||||
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");
|
||||
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()));
|
||||
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<_>>())];
|
||||
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 {
|
||||
contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect()));
|
||||
entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text)));
|
||||
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)) {
|
||||
@@ -4853,41 +4867,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4915,47 +4946,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> {
|
||||
@@ -5073,26 +5078,52 @@ 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` lowercased with the Latin accents that occur in language names folded away, so a
|
||||
/// byte-wise sort reads alphabetically. Other scripts are left as they are.
|
||||
fn name_sort_key(name: &str) -> String {
|
||||
name.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.map(|c| match c {
|
||||
'à'..='å' | 'ā' | 'ă' | 'ą' => 'a',
|
||||
'ç' | 'ć' | 'č' => 'c',
|
||||
'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e',
|
||||
'ì'..='ï' | 'ī' => 'i',
|
||||
'ñ' | 'ń' | 'ň' => 'n',
|
||||
'ò'..='ö' | 'ø' | 'ő' => 'o',
|
||||
'ś' | 'š' => 's',
|
||||
'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u',
|
||||
'ý' | 'ÿ' => 'y',
|
||||
'ź' | 'ż' | 'ž' => 'z',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod picker_order_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn picker_lists_english_first_then_names_alphabetically() {
|
||||
let order = picker_languages();
|
||||
assert_eq!(order.first(), Some(&("", "English")));
|
||||
assert_eq!(order.len(), LANGUAGES.len());
|
||||
let keys: Vec<String> = order[1..].iter().map(|(_, name)| name_sort_key(name)).collect();
|
||||
assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}");
|
||||
let pos = |code: &str| order.iter().position(|(c, _)| *c == code).unwrap_or(usize::MAX);
|
||||
assert!(pos("cs") < pos("de"), "Čeština sorts under C");
|
||||
assert!(pos("pl") < pos("ru"), "Latin names come before Cyrillic");
|
||||
assert!(pos("ru") < pos("ja"), "Cyrillic names come before CJK");
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
+132
-2
@@ -71,7 +71,8 @@ pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>;
|
||||
pub struct Services {
|
||||
/// Open-file dialog for a purpose (`open`, `place`) → path.
|
||||
pub pick_open: Option<PickFn>,
|
||||
/// Save dialog with a suggested name → path.
|
||||
/// Save dialog with a suggested name → path. The name may start with the folder the dialog
|
||||
/// opens in (see [`DesignApp::pick_save`]).
|
||||
pub pick_save: Option<PickFn>,
|
||||
pub read: Option<ReadFn>,
|
||||
pub write: Option<WriteFn>,
|
||||
@@ -223,6 +224,9 @@ pub struct UiState {
|
||||
pub pages_panel: panels::pages::PanelOptions,
|
||||
/// Edit › Color Settings (re-applied at start).
|
||||
pub color_settings: Option<designcraft_color::cms::ColorSettings>,
|
||||
/// ICC files loaded with Edit › Color Settings › Load Profile… (loaded again at start, before
|
||||
/// `color_settings`, so a loaded working space stays chosen).
|
||||
pub color_profiles: Vec<String>,
|
||||
/// View › Proof Colors and View › Proof Setup.
|
||||
pub proof_colors: bool,
|
||||
pub proof_setup: designcraft_color::cms::ProofSetup,
|
||||
@@ -236,6 +240,8 @@ pub struct UiState {
|
||||
pub flattener: String,
|
||||
/// File › Export PDF… options (persisted; see `PdfExportSettings`).
|
||||
pub pdf_export: PdfExportSettings,
|
||||
/// The Preview checkbox of Paragraph/Character Style Options, remembered between openings.
|
||||
pub style_preview: bool,
|
||||
/// View › Separations Preview: a process plate (0–3) and/or an ink limit (total, 0–4).
|
||||
#[serde(skip)]
|
||||
pub separation: Option<u8>,
|
||||
@@ -285,6 +291,10 @@ pub struct UiState {
|
||||
pub custom_workspaces: Vec<SavedWorkspace>,
|
||||
/// File › New › Document › Recent: the settings of the last documents made, newest first.
|
||||
pub recent_new_documents: Vec<designcraft_doc::build::NewDocument>,
|
||||
/// The folder of the last file saved or exported: where the save dialog starts for a
|
||||
/// document never saved.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_save_folder: Option<String>,
|
||||
/// The font menus' sample text (a key of `panels::FONT_SAMPLES`) and size (0–2).
|
||||
pub font_sample: String,
|
||||
pub font_preview_size: u8,
|
||||
@@ -383,12 +393,14 @@ impl Default for UiState {
|
||||
pages_parents_height: None,
|
||||
pages_panel: Default::default(),
|
||||
color_settings: None,
|
||||
color_profiles: Vec::new(),
|
||||
proof_colors: false,
|
||||
proof_setup: Default::default(),
|
||||
dynamic_spelling: false,
|
||||
language: String::new(),
|
||||
flattener: String::new(),
|
||||
pdf_export: PdfExportSettings::default(),
|
||||
style_preview: false,
|
||||
separation: None,
|
||||
ink_limit: None,
|
||||
display_quality: designcraft_render::DisplayQuality::High,
|
||||
@@ -412,6 +424,7 @@ impl Default for UiState {
|
||||
workspace: "Essentials".into(),
|
||||
custom_workspaces: Vec::new(),
|
||||
recent_new_documents: Vec::new(),
|
||||
last_save_folder: None,
|
||||
font_sample: "selected".into(),
|
||||
font_preview_size: 1,
|
||||
new_document_preview: false,
|
||||
@@ -751,6 +764,32 @@ impl DesignApp {
|
||||
self.ui.status = s.into();
|
||||
}
|
||||
|
||||
/// The host's save dialog for `name`, started beside the active document once it has been
|
||||
/// saved, else in the folder of the last file saved or exported (#1653). The answer's folder
|
||||
/// is remembered for the next one.
|
||||
pub(crate) fn pick_save(&mut self, name: &str) -> Option<String> {
|
||||
use std::path::{Path, PathBuf};
|
||||
let folder_of = |path: &str| Path::new(path).parent().filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf);
|
||||
let folder = self
|
||||
.session
|
||||
.active()
|
||||
.and_then(|d| d.path.as_deref())
|
||||
.and_then(folder_of)
|
||||
.or_else(|| self.ui.last_save_folder.as_ref().map(PathBuf::from));
|
||||
// Only the last part of a name goes after the folder: a title may hold a slash, and an
|
||||
// empty name would make the folder itself the suggested file.
|
||||
let file = Path::new(name).file_name();
|
||||
let suggested = match (folder, file) {
|
||||
(Some(f), Some(file)) => f.join(file).to_string_lossy().to_string(),
|
||||
_ => name.to_string(),
|
||||
};
|
||||
let picked = self.services.pick_save.as_mut().and_then(|f| f(&suggested))?;
|
||||
if let Some(folder) = folder_of(&picked) {
|
||||
self.ui.last_save_folder = Some(folder.to_string_lossy().to_string());
|
||||
}
|
||||
Some(picked)
|
||||
}
|
||||
|
||||
/// Did an IME composition end outside the IME this frame (a click, a tool switch)? egui's
|
||||
/// `should_interrupt_composition` toggles winit's IME, which doesn't reach the macOS input
|
||||
/// context: it keeps the marked text and types it again into the next composition, so the
|
||||
@@ -1106,8 +1145,22 @@ impl DesignApp {
|
||||
self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
if !self.color_applied {
|
||||
self.color_applied = true;
|
||||
for path in self.ui.color_profiles.clone() {
|
||||
if let Err(e) = self.session.execute("color.loadProfile", &json!({"path": path})) {
|
||||
log::warn!("Color Settings: {e}");
|
||||
self.status_error("Color Settings", e);
|
||||
}
|
||||
}
|
||||
if let Some(cs) = self.ui.color_settings.clone() {
|
||||
let _ = designcraft_color::cms::set_active(&cs.with_known_profiles());
|
||||
let known = cs.with_known_profiles();
|
||||
for (saved, used) in [(&cs.rgb, &known.rgb), (&cs.cmyk, &known.cmyk)] {
|
||||
if saved != used {
|
||||
let e = format!("saved profile {saved} is not available; using {used}");
|
||||
log::warn!("Color Settings: {e}");
|
||||
self.status_error("Color Settings", e);
|
||||
}
|
||||
}
|
||||
let _ = designcraft_color::cms::set_active(&known);
|
||||
}
|
||||
}
|
||||
let scale = self.ui.ui_scale.clamp(0.5, 3.0);
|
||||
@@ -1834,6 +1887,70 @@ mod tests {
|
||||
assert_eq!(app.view_info().unit, Unit::Inches);
|
||||
}
|
||||
|
||||
/// Save and Export PDF dialogs started in the home folder (#1653): they start beside a saved
|
||||
/// document, and for one never saved in the folder of the last save or export.
|
||||
#[test]
|
||||
fn save_dialogs_start_beside_the_document_or_in_the_last_folder() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let elsewhere = tempfile::tempdir().unwrap();
|
||||
let asked: Rc<RefCell<Vec<String>>> = Rc::default();
|
||||
let answer: Rc<RefCell<Option<String>>> = Rc::default();
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
|
||||
let (seen, reply) = (asked.clone(), answer.clone());
|
||||
app.services.pick_save = Some(Box::new(move |name: &str| {
|
||||
seen.borrow_mut().push(name.to_string());
|
||||
reply.borrow().clone()
|
||||
}));
|
||||
app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(())));
|
||||
let last = |asked: &Rc<RefCell<Vec<String>>>| asked.borrow().last().cloned().unwrap_or_default();
|
||||
let export_pdf = |app: &mut DesignApp| {
|
||||
app.ui.dialog = Some(dialogs::Dialog::new("pdfExport", json!({})));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Report"));
|
||||
dialogs::confirm(app).unwrap();
|
||||
};
|
||||
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title);
|
||||
app.run("app.saveDialog", json!({})).unwrap();
|
||||
assert_eq!(last(&asked), untitled, "never saved, nothing remembered: the system's own folder");
|
||||
|
||||
let saved = home.path().join("Brochure.designcraft");
|
||||
*answer.borrow_mut() = Some(saved.to_string_lossy().to_string());
|
||||
app.run("app.saveDialog", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*saved.to_string_lossy()));
|
||||
// An export into another folder becomes the last folder used.
|
||||
*answer.borrow_mut() = Some(elsewhere.path().join("Proof.pdf").to_string_lossy().to_string());
|
||||
export_pdf(&mut app);
|
||||
*answer.borrow_mut() = None;
|
||||
export_pdf(&mut app);
|
||||
assert_eq!(last(&asked), home.path().join("Report.pdf").to_string_lossy(), "a saved document exports beside itself");
|
||||
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title);
|
||||
app.run("app.saveDialog", json!({})).unwrap();
|
||||
assert_eq!(last(&asked), elsewhere.path().join(untitled).to_string_lossy(), "a new document starts in the last folder used");
|
||||
}
|
||||
|
||||
/// No name (Save with no document) suggested the remembered folder itself as the file, and a
|
||||
/// title with a slash moved the suggestion out of the document's folder.
|
||||
#[test]
|
||||
fn save_dialog_suggestions_keep_the_folder_and_only_a_file_name() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let asked: Rc<RefCell<Vec<String>>> = Rc::default();
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
|
||||
let seen = asked.clone();
|
||||
app.services.pick_save = Some(Box::new(move |name: &str| {
|
||||
seen.borrow_mut().push(name.to_string());
|
||||
None
|
||||
}));
|
||||
app.ui.last_save_folder = Some(folder.path().join("Docs").to_string_lossy().to_string());
|
||||
app.pick_save("");
|
||||
app.pick_save("Q1/Q2.pdf");
|
||||
assert_eq!(*asked.borrow(), ["".to_string(), folder.path().join("Docs").join("Q2.pdf").to_string_lossy().to_string()]);
|
||||
}
|
||||
|
||||
/// An app with one document that has an unsaved edit.
|
||||
fn app_with_unsaved_document() -> DesignApp {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
|
||||
@@ -2929,6 +3046,19 @@ mod browser_import_tests {
|
||||
assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_options_refuse_a_page_range_instead_of_placing_page_one() {
|
||||
for spec in ["4-5", "2,5", "2-3", "2.5", "-3", "0", "nan", "inf"] {
|
||||
let mut app = app();
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request, "three.pdf", pdf());
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(spec));
|
||||
assert!(dialogs::confirm(&mut app).is_err(), "{spec}");
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty(), "{spec}");
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport", "{spec}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_loads_the_cursor_unless_a_frame_can_take_the_graphic() {
|
||||
let mut app = app();
|
||||
|
||||
+115
-8
@@ -1071,7 +1071,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
}
|
||||
"app.saveDialog" => {
|
||||
let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).unwrap_or_default();
|
||||
if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) {
|
||||
if let Some(path) = app.pick_save(&name) {
|
||||
return Some(app.run("file.saveAs", json!({"path": path})));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
@@ -1082,7 +1082,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
}
|
||||
"app.saveCopyDialog" => {
|
||||
let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default();
|
||||
if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) {
|
||||
if let Some(path) = app.pick_save(&name) {
|
||||
return Some(app.run("file.saveACopy", json!({"path": path})));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
@@ -1156,7 +1156,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
"app.exportText" => export_bytes(app, p, "rtf", "file.exportText"),
|
||||
"app.packageDialog" => {
|
||||
let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default();
|
||||
match app.services.pick_save.as_mut().and_then(|f| f(&name)) {
|
||||
match app.pick_save(&name) {
|
||||
Some(dir) => {
|
||||
let r = app.run("file.package", json!({"dir": dir}));
|
||||
if let Ok(v) = &r {
|
||||
@@ -1217,7 +1217,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
"app.saveSwatches" => {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(s) => Some(s.to_string()),
|
||||
None => app.services.pick_save.as_mut().and_then(|f| f("Swatches.ase")),
|
||||
None => app.pick_save("Swatches.ase"),
|
||||
};
|
||||
let Some(path) = path else { return Some(Ok(Value::Null)) };
|
||||
let r = match app.run("swatch.save", json!({})) {
|
||||
@@ -2011,7 +2011,7 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
|
||||
Some(s) => Some(s.to_string()),
|
||||
None => {
|
||||
let name = format!("{}-p{}.png", st.doc.title, page + 1);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&name))
|
||||
app.pick_save(&name)
|
||||
}
|
||||
};
|
||||
let Some(path) = path else { return Ok(Value::Null) };
|
||||
@@ -2029,7 +2029,7 @@ fn export_idml(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
|
||||
Some(s) => Some(s.to_string()),
|
||||
None => {
|
||||
let name = format!("{}.idml", st.doc.title);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&name))
|
||||
app.pick_save(&name)
|
||||
}
|
||||
};
|
||||
let Some(path) = path else { return Ok(Value::Null) };
|
||||
@@ -2049,7 +2049,7 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result<
|
||||
Some(s) => Some(s.to_string()),
|
||||
None => {
|
||||
let name = format!("{}.{ext}", st.doc.title);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&name))
|
||||
app.pick_save(&name)
|
||||
}
|
||||
};
|
||||
let Some(path) = path else { return Ok(Value::Null) };
|
||||
@@ -2077,7 +2077,7 @@ pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result<Value, String
|
||||
Some(s) => Some(s.to_string()),
|
||||
None => {
|
||||
let name = format!("{}.pdf", st.doc.title);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&name))
|
||||
app.pick_save(&name)
|
||||
}
|
||||
};
|
||||
let Some(path) = path else { return Ok(Value::Null) };
|
||||
@@ -5784,4 +5784,111 @@ mod tests {
|
||||
assert!(texts.iter().any(|t| t == "Recent files"), "{texts:?}");
|
||||
assert!(texts.iter().any(|t| t == "gone"), "the card names it without `.designcraft`: {texts:?}");
|
||||
}
|
||||
|
||||
/// On a short window (1366 x 768 laptops) the start screen is taller than the window; the part
|
||||
/// below the fold has to be reachable by scrolling instead of being cut off.
|
||||
#[test]
|
||||
fn start_screen_scrolls_when_the_window_is_too_short() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, "");
|
||||
let size = egui::vec2(1366.0, 300.0);
|
||||
let mut tile_y = |events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().find_map(|s| match &s.shape {
|
||||
// The preset tiles paint their names even when scrolled out of view (labels skip that).
|
||||
egui::Shape::Text(t) if t.galley.text() == "Tabloid" => Some(t.pos.y),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let at = egui::pos2(600.0, 150.0);
|
||||
let mut before = None;
|
||||
for _ in 0..3 {
|
||||
before = tile_y(vec![egui::Event::PointerMoved(at)]);
|
||||
}
|
||||
let before = before.expect("the Tabloid tile is laid out");
|
||||
let wheel = egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -400.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
};
|
||||
let mut after = tile_y(vec![egui::Event::PointerMoved(at), wheel]);
|
||||
for _ in 0..30 {
|
||||
after = tile_y(Vec::new());
|
||||
}
|
||||
let after = after.expect("the Tabloid tile is laid out");
|
||||
assert!(after < before - 50.0, "the start screen should scroll: {before} -> {after}");
|
||||
}
|
||||
|
||||
// Regression test for #329: on the start screen (no document) the Help menu's links were greyed out.
|
||||
#[test]
|
||||
fn help_links_are_enabled_without_a_document() {
|
||||
let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
assert!(app.session.active().is_none());
|
||||
let ids: Vec<&str> = UI_COMMANDS.iter().map(|c| c.0).filter(|id| id.starts_with("help.")).collect();
|
||||
assert!(ids.contains(&"help.discord") && ids.contains(&"help.github"), "{ids:?}");
|
||||
for id in ids {
|
||||
assert!(menu_enabled(&app, id), "{id} must work from the start screen");
|
||||
}
|
||||
assert!(!menu_enabled(&app, "view.rulers"), "other UI commands still need a document");
|
||||
}
|
||||
|
||||
// Regression test for #248: the start screen offered no A6 sheet (it listed only the first ten presets).
|
||||
#[test]
|
||||
fn start_screen_offers_every_print_preset_including_a6() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let texts = start_screen_texts(&mut app);
|
||||
for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) {
|
||||
assert!(texts.iter().any(|t| t == p.name), "start screen lacks the {} card: {texts:?}", p.name);
|
||||
}
|
||||
assert!(texts.iter().any(|t| t == "A6"), "{texts:?}");
|
||||
let a6 = designcraft_doc::build::PRESETS.iter().find(|p| p.name == "A6").unwrap();
|
||||
assert!((a6.width - 105.0 * 72.0 / 25.4).abs() < 0.01 && (a6.height - 148.0 * 72.0 / 25.4).abs() < 0.01, "{a6:?}");
|
||||
let page = crate::panels::properties::PAGE_PRESETS.iter().find(|p| p.0 == "A6").expect("Properties page-size list has A6");
|
||||
assert!((page.1 - a6.width).abs() < 0.01 && (page.2 - a6.height).abs() < 0.01, "{page:?} vs {a6:?}");
|
||||
}
|
||||
|
||||
// Regression test for #249: a menu row only reacted over its text, not across the whole row.
|
||||
#[test]
|
||||
fn menu_rows_are_selectable_across_the_full_width() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0));
|
||||
let entries = vec![cmd_item("app.preferences", Value::Null)];
|
||||
let mut tick = 0;
|
||||
let mut frame = |app: &mut DesignApp, events: Vec<egui::Event>| {
|
||||
tick += 1;
|
||||
let mut row = egui::Rect::NOTHING;
|
||||
let mut output =
|
||||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| {
|
||||
egui::Area::new(egui::Id::new("menu_rows")).fixed_pos(egui::pos2(50.0, 50.0)).show(ui.ctx(), |ui| {
|
||||
let before = ui.cursor().min;
|
||||
menu_column(app, ui, &[("Test", entries.clone())]);
|
||||
row = egui::Rect::from_min_max(before, ui.min_rect().max);
|
||||
});
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
row
|
||||
};
|
||||
frame(&mut app, vec![]);
|
||||
let row = frame(&mut app, vec![]);
|
||||
assert!(row.width() >= 239.0, "the column is at least 240 wide: {row:?}");
|
||||
let far = egui::pos2(row.right() - 6.0, row.top() + 8.0);
|
||||
for pressed in [true, false] {
|
||||
frame(
|
||||
&mut app,
|
||||
vec![
|
||||
egui::Event::PointerMoved(far),
|
||||
egui::Event::PointerButton { pos: far, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
);
|
||||
}
|
||||
assert!(app.ui.dialog.is_some(), "clicking the empty end of the Preferences… row must choose it");
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Library…"))).clicked() {
|
||||
let path = app.services.pick_save.as_mut().and_then(|f| f("Library.dclib"));
|
||||
let path = app.pick_save("Library.dclib");
|
||||
let _ = app.run("library.new", json!({"path": path}));
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked()
|
||||
@@ -90,7 +90,7 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked()
|
||||
&& let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.dcbook"))
|
||||
&& let Some(path) = app.pick_save("Book.dcbook")
|
||||
&& let Err(e) = app.run("book.new", json!({"path": path}))
|
||||
{
|
||||
app.status_error("Book", e);
|
||||
@@ -154,7 +154,7 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let _ = app.run("book.syncStyles", json!({}));
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked()
|
||||
&& let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.pdf"))
|
||||
&& let Some(path) = app.pick_save("Book.pdf")
|
||||
&& let Err(e) = app.run("book.exportPdf", json!({"path": path}))
|
||||
{
|
||||
app.status_error("Book", e);
|
||||
|
||||
@@ -982,7 +982,9 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let to = app.ui.align_to.clone();
|
||||
// A key object switches Align To to Key Object; choosing another target drops the key.
|
||||
let key = app.session.active().and_then(|st| st.selection.key.filter(|_| st.selection.items.len() > 1));
|
||||
let to = if key.is_some() { "keyObject".to_string() } else { app.ui.align_to.clone() };
|
||||
let to_label = match to.as_str() {
|
||||
"selection" => "Selection",
|
||||
"keyObject" => "Key Object",
|
||||
@@ -1001,7 +1003,10 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) {
|
||||
("Align to Page", "page"),
|
||||
("Align to Spread", "spread"),
|
||||
] {
|
||||
if ui.selectable_label(app.ui.align_to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
|
||||
if ui.selectable_label(to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
|
||||
if let Some(k) = key.filter(|_| id != "keyObject") {
|
||||
let _ = app.run("selection.key", json!({"id": k.0}));
|
||||
}
|
||||
app.ui.align_to = id.into();
|
||||
ui.close();
|
||||
}
|
||||
|
||||
@@ -18,53 +18,63 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
// Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in
|
||||
// colour groups are listed under their folder.
|
||||
let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect();
|
||||
for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
|
||||
swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref());
|
||||
}
|
||||
for g in &doc.color_groups {
|
||||
let open_id = egui::Id::new(("color_group_open", &g.name));
|
||||
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
// A gradient dragged from the Gradient panel's thumbnail is dropped on the list, colour
|
||||
// groups included.
|
||||
let (_, dropped) = ui.dnd_drop_zone::<GradientDrag, _>(egui::Frame::NONE, |ui| {
|
||||
for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
|
||||
swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref());
|
||||
}
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
t.text_dim,
|
||||
);
|
||||
// A folder.
|
||||
let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0));
|
||||
ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside);
|
||||
ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim));
|
||||
ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text);
|
||||
// A double click's first click folded the group: it stays as it was.
|
||||
if resp.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() {
|
||||
for g in &doc.color_groups {
|
||||
let open_id = egui::Id::new(("color_group_open", &g.name));
|
||||
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
t.text_dim,
|
||||
);
|
||||
// A folder.
|
||||
let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0));
|
||||
ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside);
|
||||
ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim));
|
||||
ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text);
|
||||
// A double click's first click folded the group: it stays as it was.
|
||||
if resp.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() {
|
||||
let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if open {
|
||||
for n in &g.swatches {
|
||||
if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) {
|
||||
swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref());
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() {
|
||||
let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() {
|
||||
let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if open {
|
||||
for n in &g.swatches {
|
||||
if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) {
|
||||
swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// An unnamed gradient becomes "New Gradient Swatch"; one that is a swatch already stays one.
|
||||
if let Some(g) = dropped
|
||||
&& doc.swatch(&g.0).is_some_and(|w| w.hidden)
|
||||
{
|
||||
let _ = app.run("swatch.new", json!({"swatch": g.0}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
// InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked).
|
||||
@@ -575,6 +585,11 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SwatchDrag(pub String);
|
||||
|
||||
/// The selection's gradient dragged from the Gradient panel's thumbnail (its swatch name): the
|
||||
/// Swatches panel takes it as a new gradient swatch.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GradientDrag(pub String);
|
||||
|
||||
/// A colour in `object.gradient` form, keeping its model (CMYK stays CMYK).
|
||||
fn color_json(c: &designcraft_color::Color) -> serde_json::Value {
|
||||
use designcraft_color::Color;
|
||||
@@ -680,8 +695,18 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let full = ui.available_width().max(60.0);
|
||||
ui.add_enabled_ui(has_item, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click_and_drag());
|
||||
thumbnail(ui.painter(), r.shrink(1.0), &g, t.field_border);
|
||||
// Drag the gradient onto the Swatches panel to keep it as a swatch.
|
||||
if is_gradient {
|
||||
resp.dnd_set_drag_payload(GradientDrag(fill.swatch.clone()));
|
||||
if resp.dragged()
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gradient_drag")));
|
||||
thumbnail(&painter, egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(18.0, 18.0)), &g, t.field_border);
|
||||
}
|
||||
}
|
||||
// Clicking the thumbnail applies the gradient shown (InDesign's way to start one).
|
||||
if resp.clicked() && !is_gradient {
|
||||
send = Some(json!({}));
|
||||
@@ -1025,6 +1050,64 @@ mod gradient_panel_tests {
|
||||
assert_eq!(g.stops[1].color, Color::cmyk(1.0, 0.0, 0.0, 0.0));
|
||||
assert!((g.stops[1].offset - 0.5).abs() < 0.05, "{}", g.stops[1].offset);
|
||||
}
|
||||
|
||||
/// The Gradient panel's thumbnail drags the selection's gradient onto the Swatches panel,
|
||||
/// which keeps it as a gradient swatch.
|
||||
#[test]
|
||||
fn a_gradient_dragged_onto_the_swatches_becomes_a_swatch() {
|
||||
let mut app = app_with_frame();
|
||||
app.run("object.gradient", json!({})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120})).unwrap();
|
||||
let named =
|
||||
|app: &DesignApp| app.session.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect::<Vec<_>>();
|
||||
let before = named(&app);
|
||||
let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
|
||||
h.run_steps(6);
|
||||
drag_gradient_to(&mut h, "[Black]");
|
||||
let after = named(&h.state().app);
|
||||
assert_eq!(after.len(), before.len() + 1);
|
||||
assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch"));
|
||||
let d = &h.state().app.session.doc().unwrap().doc;
|
||||
assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })));
|
||||
let id = h.state().app.session.doc().unwrap().selection.items[0];
|
||||
assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name");
|
||||
}
|
||||
|
||||
/// Drag the Gradient panel's thumbnail onto the Swatches panel row labelled `label`.
|
||||
fn drag_gradient_to(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str) {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
// The thumbnail sits left of the Type row.
|
||||
let ty = h.get_by_label("Type:").rect();
|
||||
let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0);
|
||||
let to = h.get_by_label(label).rect().center();
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for i in 1..=10 {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0)));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// A colour group's row is part of the list too: a gradient dropped on it becomes a swatch.
|
||||
#[test]
|
||||
fn a_gradient_dropped_on_a_colour_group_becomes_a_swatch() {
|
||||
let mut app = app_with_frame();
|
||||
app.run("object.gradient", json!({})).unwrap();
|
||||
let group = app.run("swatch.newColorGroup", json!({})).unwrap()["name"].as_str().unwrap().to_string();
|
||||
app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap();
|
||||
// Tall enough to show the group's row below the swatches.
|
||||
app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120, "height": 700})).unwrap();
|
||||
let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
|
||||
h.run_steps(6);
|
||||
drag_gradient_to(&mut h, &group);
|
||||
let d = &h.state().app.session.doc().unwrap().doc;
|
||||
assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })), "dropped on the group's row");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! A working space loaded with Edit › Color Settings › Load Profile… is active again after a
|
||||
//! restart (#1474). Colour settings are process-wide, so this lives in the integration tests.
|
||||
|
||||
use designcraft_color::cms::{self, ColorSettings};
|
||||
use designcraft_engine::Session;
|
||||
use designcraft_ui_egui::{DesignApp, Services, UiState};
|
||||
use serde_json::json;
|
||||
|
||||
/// The app's first frame, where saved settings are applied.
|
||||
fn start(app: &mut DesignApp) {
|
||||
let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| app.logic(&ui.ctx().clone()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_working_space_survives_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("restart-test.icc");
|
||||
let mut profile = cms::icc_out::generic_cmyk_profile().unwrap().clone();
|
||||
profile.description = "Restart Test CMYK".into();
|
||||
std::fs::write(&path, profile.with_encoded_bytes().to_bytes().as_slice()).unwrap();
|
||||
|
||||
// What ui.json holds after Load Profile… and choosing that profile in a previous session.
|
||||
let chosen = ColorSettings { cmyk: "Restart Test CMYK".into(), ..Default::default() };
|
||||
let saved = json!({"colorProfiles": [path.to_string_lossy()], "colorSettings": chosen});
|
||||
|
||||
// This process has never loaded the profile: only the built-in ones are known.
|
||||
assert!(!cms::profiles().iter().any(|p| p.name == chosen.cmyk));
|
||||
let mut app = DesignApp::new(Session::new(), Services::default());
|
||||
app.ui = serde_json::from_value::<UiState>(saved).unwrap();
|
||||
start(&mut app);
|
||||
let active = cms::active_settings();
|
||||
cms::set_active(&ColorSettings::default()).unwrap();
|
||||
assert_eq!(active, chosen, "the loaded working space is active again");
|
||||
|
||||
// A saved profile that can't be loaded any more is reported, not silently replaced.
|
||||
let gone = json!({"colorProfiles": [dir.path().join("gone.icc").to_string_lossy()], "colorSettings": ColorSettings { cmyk: "Gone CMYK".into(), ..Default::default() }});
|
||||
let mut app = DesignApp::new(Session::new(), Services::default());
|
||||
app.ui = serde_json::from_value::<UiState>(gone).unwrap();
|
||||
start(&mut app);
|
||||
assert_eq!(cms::active_settings(), ColorSettings::default());
|
||||
assert!(app.ui.status.contains("Gone CMYK"), "{}", app.ui.status);
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod color_profiles_restart;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_job_lifetime_control;
|
||||
@@ -63,6 +63,16 @@ The image must contain at most 16 million pixels. PDF/SVG graphics are unsupport
|
||||
not Adobe's palette algorithm. In the UI, click an image with the Color Theme Tool (Shift+I), or choose Color Theme…
|
||||
in the Swatches panel menu with one image selected. Cancel keeps the document unchanged.
|
||||
|
||||
## PDF/X-4 validation
|
||||
|
||||
`designcraft-cli validate-pdf FILE.pdf` prints JSON with `valid` and `issues`, and exits non-zero
|
||||
when a built-in check fails. Checks cover PDF version, output intent and profile, Info and XMP
|
||||
identification, image interpolation, encryption, trim boxes, Type 3 fonts, device RGB under a CMYK
|
||||
output intent, and unembedded fonts drawing visible text (including placed PDF forms). Render
|
||||
mode 3 (invisible OCR text) is exempt from the font-embedding check. PDF/X-4 export reports these
|
||||
issues as warnings; it does not embed or outline placed fonts. These checks are not a complete
|
||||
external PDF/X certification.
|
||||
|
||||
## Data merge
|
||||
|
||||
`data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
|
||||
InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
|
||||
@@ -55,6 +55,14 @@ layer survive interchange; literal XML and round-trip tests are in
|
||||
| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% |
|
||||
| Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% |
|
||||
|
||||
PDF/X-4 export warns about unembedded fonts that draw visible text, including inside placed PDF
|
||||
forms; `designcraft-cli validate-pdf FILE.pdf` reports the same issues, with font names and pages
|
||||
(#909). Invisible text (render mode 3) is exempt from this font-embedding check. Detection does not
|
||||
automatically embed or outline fonts; embedding, substitution or outlining of placed-PDF fonts
|
||||
remains missing. A document-wide operation limit and a form-nesting depth limit report incomplete
|
||||
font checks rather than traversing pathological nesting. These built-in checks are not complete
|
||||
external PDF/X certification.
|
||||
|
||||
## Placed and imported content
|
||||
|
||||
| Format | Ext | InDesign | DesignCraft | Notes | Weight |
|
||||
@@ -114,6 +122,7 @@ up to 30 points.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-19 | minor | PDF/X-4 export and `validate-pdf` report visible unembedded fonts by name and page, including placed forms; invisible text is exempt, operation- or depth-limited checks report incompleteness, and automatic font repair remains missing (#909) |
|
||||
| 2026-10-15 | minor | IDML retains liquid versus ruler guide types on document and parent pages, with literal XML and mixed-guide round-trip coverage; object liquid constraints remain unsupported (#872) |
|
||||
| 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) |
|
||||
| 2026-10-13 | minor | `.indd` and `.indt` read through storytold/indd-utils; INDD remaining work is a real-file corpus (10–30 h); score unchanged until it is measured |
|
||||
|
||||
+20
-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 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
|
||||
InDesign professional from doing real work. This is the work list: agents pick from the top.
|
||||
@@ -123,6 +123,18 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
spot, marks and bleed, the options dialog with remembered settings (#196).
|
||||
- **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md)
|
||||
|
||||
### 28. Placed-PDF font repair for PDF/X-4 (FF)
|
||||
- **Missing:** embedding, substitution or outlining of unembedded fonts in placed PDFs; export
|
||||
does not automatically embed or outline them.
|
||||
- **Have:** PDF/X-4 export warnings and `validate-pdf` report unembedded fonts drawing visible
|
||||
text, including inside placed PDF forms, with font names and pages (#909). Invisible text
|
||||
(render mode 3) is exempt from this font-embedding check. A document-wide operation limit and a
|
||||
form-nesting depth limit report incomplete font checks rather than traversing pathological
|
||||
nesting.
|
||||
- **Impact:** detection now ships, but a placed PDF with visible unembedded fonts still needs
|
||||
font repair before it can meet PDF/X-4 requirements.
|
||||
- **Doc:** [file-format-parity.md](file-format-parity.md)
|
||||
|
||||
### 10. Printing (H, F)
|
||||
- **Missing:** PostScript, a printer-driver options UI, separations to device, print presets, or trapping.
|
||||
- **Have:** `file.print` exports selected pages/spreads with marks and bleed, then submits through
|
||||
@@ -206,13 +218,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 +259,8 @@ 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-19 | minor | Gap 28 added for remaining placed-PDF font embedding, substitution or outlining; visible unembedded font detection now ships in PDF/X-4 export and `validate-pdf`, with invisible text exempt and operation- or depth-limited checks reported incomplete (#909) |
|
||||
| 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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-1
@@ -182,7 +182,9 @@ and `text.move` return, a story's `length` in `get_story` and `inspect_document`
|
||||
`get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15
|
||||
and its second character starts at 3. An offset inside a character snaps back to the start of that character, except
|
||||
in `story.replaceRange`: there an offset inside a character, an `end` past the end of the story or a `start` after `end`
|
||||
is an error that gives the story length in bytes, and the story is left unchanged.
|
||||
is an error that gives the story length in bytes, and the story is left unchanged. A `find.find` or `find.next` match
|
||||
that has a `cell` (`{table, row, col}`) is in that table cell's text, and its `start` / `end` count from the start of
|
||||
the cell.
|
||||
|
||||
## Text and image fitting
|
||||
|
||||
|
||||
@@ -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 (PDF/X-4 visible unembedded font detection, #909; 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,18 +211,25 @@ 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 |
|
||||
| Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles |
|
||||
| Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback |
|
||||
| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel |
|
||||
| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), with visible unembedded font detection (below); no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel |
|
||||
| XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth |
|
||||
| View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) |
|
||||
| Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action |
|
||||
| Accessibility | 100% | 50% | 15–25 | tagged PDF exists; PDF/UA validation, reading order depth, screen-reader support in the UI |
|
||||
|
||||
PDF/X-4 export warnings and `validate-pdf` report unembedded fonts drawing visible text, including
|
||||
inside placed PDF forms, with font names and pages (#909). Invisible text (render mode 3) is exempt
|
||||
from the font-embedding check. Fonts are not embedded or outlined automatically; embedding,
|
||||
substitution or outlining of placed-PDF fonts remains missing ([gap 28](gaps.md#28-placed-pdf-font-repair-for-pdfx-4-ff)).
|
||||
A document-wide operation limit and a form-nesting depth limit report incomplete font checks rather
|
||||
than traversing pathological nesting. This is detection, not complete external PDF/X certification.
|
||||
|
||||
## Remaining effort and calibration
|
||||
|
||||
**Calibrated against this repo's history.** The first commit was 2026-10-01 09:00; by 2026-10-10
|
||||
@@ -299,7 +306,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 +375,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`).
|
||||
@@ -463,6 +470,7 @@ Task notes that headed ROADMAP.md:
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-19 | minor | Output assessment records visible unembedded font detection in PDF/X-4 export and `validate-pdf`, the invisible-text exemption, incomplete-check reporting at the operation and nesting-depth limits, and remaining placed-PDF font repair (#909); readiness and hours unchanged |
|
||||
| 2026-10-13 | minor | `.indd` and `.indt` open through storytold/indd-utils; INDD remaining work 10–30 h (real-file corpus); readiness unchanged until it is measured |
|
||||
| 2026-10-12 | minor | Text threads by hand; stage note and distance to alpha follow the gate (5–7 h, #165); percentages unchanged |
|
||||
| 2026-10-11 | minor | Spring-loaded Zoom navigation added to the working feature inventory (DC-1); readiness estimates unchanged |
|
||||
|
||||
@@ -17,7 +17,7 @@ Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl.
|
||||
4. Under **Compression**, tick or clear **Compress images**.
|
||||
5. Under **Marks and Bleeds**, tick **Include document bleed**, **Crop Marks**, **Bleed Marks** or **Page Information** as your printer asks. **Weight:** and **Offset:** set the marks.
|
||||
6. Under **Advanced**, pick a **Standard:** (**None**, **PDF/X-4** or **PDF/A-2b**) and decide on **Tagged PDF**.
|
||||
7. Click **OK**, then choose where to save. The file name starts as your document's title with `.pdf`.
|
||||
7. Click **OK**, then choose where to save. The file name starts as your document's title with `.pdf`. The save dialog opens in your document's folder, or, if the document has never been saved, in the folder you last saved or exported to.
|
||||
|
||||
## Presets
|
||||
|
||||
|
||||
+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