Merge storytold/main into feat/multi-automation-roots

This commit is contained in:
Brandon Thomas
2026-10-24 10:24:14 -04:00
59 changed files with 2266 additions and 107 deletions
+7
View File
@@ -359,6 +359,12 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change.
- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change.
- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change.
- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change.
- **2026-10-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change.
- **2026-10-24** · Story panel (Window › Type & Tables › Story): Optical Margin Alignment and its size for the stories of the text selection or the selected text frames, disabled without either; `type.selectionAttrs` reports them as `storyOptions`, null where the stories differ. The Paragraph panel menu has Ignore Optical Margin in place of the per-paragraph Optical Margin Alignment switch, which stays in `type.para`, and Paragraph Style Options › Indents and Spacing has Ignore Optical Margin (#947). No readiness percentage change.
- **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change.
- **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change.
- **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change.
- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
@@ -375,6 +381,7 @@ Newest first.
- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change.
+31 -14
View File
@@ -48,6 +48,9 @@ pub struct Spacing {
pub hyph_zone: f64,
/// Optical margin alignment: punctuation hangs outside the measure (counted as extra width).
pub optical: bool,
/// The type size the hang amounts are set for (Story ▸ Optical Margin Alignment's size);
/// `None`: each glyph's own size, so the hang is [`hang`]'s proportion of its advance.
pub optical_size: Option<f64>,
/// Korean text breaks between syllables instead of at spaces.
pub korean_char_breaks: bool,
/// 0: minimum adjustment; 1: push in; 2: push out first; 3: push out only.
@@ -72,6 +75,7 @@ impl Default for Spacing {
ragged_stretch: 24.0,
hyph_zone: 0.0,
optical: false,
optical_size: None,
korean_char_breaks: false,
kinsoku_priority: 0,
}
@@ -318,16 +322,32 @@ pub fn hang(c: char) -> (f64, f64) {
}
}
fn hang_left(g: &Glyph) -> f64 {
hang(g.ch).0 * g.adv
/// How much more than [`hang`]'s proportions `g` hangs: the hang is set for
/// [`Spacing::optical_size`] and grows with it, relative to the glyph's own size.
pub(crate) fn optical_scale(g: &Glyph, sp: &Spacing) -> f64 {
match sp.optical_size {
Some(s) if s.is_finite() && g.size.is_finite() && g.size > 0.0 => s / g.size,
_ => 1.0,
}
}
/// Optical hang of `g` at the start of a line (0 without optical margin alignment).
pub(crate) fn hang_left(g: &Glyph, sp: &Spacing) -> f64 {
if sp.optical { hang(g.ch).0 * g.adv * optical_scale(g, sp) } else { 0.0 }
}
/// Optical hang of `g` at the end of a line (0 without optical margin alignment).
pub(crate) fn hang_right(g: &Glyph, sp: &Spacing) -> f64 {
if sp.optical { hang(g.ch).1 * g.adv * optical_scale(g, sp) } else { 0.0 }
}
fn right_hang(g: &Glyph, sp: &Spacing) -> f64 {
g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) - g.moji.end
g.cjk_hang.max(hang_right(g, sp)) - g.moji.end
}
fn hang_right(g: &Glyph) -> f64 {
hang(g.ch).1 * g.adv
/// The part of a hyphen after `g` (of width `hw`) that hangs past the line's end.
fn hyphen_hang(g: &Glyph, hw: f64, sp: &Spacing) -> f64 {
if sp.optical { hw * hang('-').1 * optical_scale(g, sp) } else { 0.0 }
}
/// Advance of the hyphen glyph that would follow `g` (cached per face).
@@ -428,15 +448,13 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
let mut p = Item::penalty(hw, sp.hyphen_penalty, true);
p.auto = true;
p.zone_from = last_space;
if sp.optical {
p.hang = hw * hang('-').1;
}
p.hang = hyphen_hang(g, hw, sp);
push_inword(&mut it, &mut ig, p, i + 1, sp);
continue;
}
let (st, sh) = sp.box_elastic(g);
let mut bx = Item::boxed(g.adv, st, sh);
bx.hang = (if sp.optical { hang_left(g) } else { 0.0 }) - g.moji.start;
bx.hang = hang_left(g, sp) - g.moji.start;
bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3);
it.push(bx);
ig.push(i);
@@ -457,9 +475,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
let mut p = Item::penalty(hw, sp.hyphen_penalty, true);
p.auto = true;
p.zone_from = last_space;
if sp.optical {
p.hang = hw * hang('-').1;
}
p.hang = hyphen_hang(g, hw, sp);
push_inword(&mut it, &mut ig, p, i + 1, sp);
} else if emergency_after.get(i) == Some(&true) {
let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false);
@@ -876,7 +892,7 @@ pub fn greedy(
let may_hyphenate = |hyphens: u32| sp.hyphen_limit == 0 || hyphens < sp.hyphen_limit;
while start < n {
let w = width(line).max(1.0);
let mut x = (if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }) + glyphs[start].moji.start;
let mut x = -hang_left(&glyphs[start], sp) + glyphs[start].moji.start;
let mut shrink = crate::mojikumi::start_elastic(&glyphs[start], sp.justify, sp.kinsoku_priority != 3)[1];
let mut last_natural: Option<(usize, bool)> = None;
let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen)
@@ -939,7 +955,8 @@ pub fn greedy(
last_natural = last_ok;
}
} else if hyph_after[i] && may_hyphenate(hyphens) {
let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 };
let hw = hyphen_width(g, &mut hy_cache);
let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0);
if x + hy <= w + shrink + FIT_EPS {
last_ok = Some((i + 1, true));
}
+18
View File
@@ -23,6 +23,13 @@ use designcraft_doc::story;
pub const MAX_PATTERN: usize = 4096;
/// Backtracking steps one search may take before it gives up.
pub const BACKTRACK_LIMIT: usize = 200_000;
/// Matches one GREP style may contribute to one paragraph.
///
/// [`BACKTRACK_LIMIT`] bounds a single search, not a scan: `find_all` pays it again for every
/// match, so a pattern tuned just under the limit cost `BACKTRACK_LIMIT × matches` per paragraph
/// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and
/// a scan that reaches it stops with what it has.
pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096;
/// Compiled program size cap (bytes) for the underlying regex engine.
const SIZE_LIMIT: usize = 4 << 20;
@@ -378,6 +385,17 @@ impl Grep {
self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect()
}
/// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it
/// doesn't count toward a GREP style's cap.
pub fn find_nonempty(&self, text: &str, limit: usize) -> Result<Vec<Range<usize>>, GrepError> {
self.re
.find_iter(text)
.filter(|m| !m.as_ref().is_ok_and(|m| m.range().is_empty()))
.take(limit)
.map(|m| m.map(|m| m.range()).map_err(run_err))
.collect()
}
/// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see
/// [`translate_replacement`]). Stops after the first match when `first_only`.
pub fn replacements(&self, text: &str, template: &str, first_only: bool) -> Result<Vec<(Range<usize>, String)>, GrepError> {
+23 -8
View File
@@ -1063,9 +1063,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
if !story.endnotes.is_empty() {
// Endnote reference numbers continue through the document.
let first = doc.endnote_start(story.id) + story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32;
let first = doc.endnote_start(story.id).saturating_add(story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32);
for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::ENDNOTE_REF).enumerate() {
sub.notes.insert(prange.start + i, doc.endnote_options.label(first + k as u32));
sub.notes.insert(prange.start + i, doc.endnote_options.label(first.saturating_add(k as u32)));
}
}
if !story.objects.is_empty() {
@@ -1136,7 +1136,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let cols_here = &cols[cur.fi];
let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns);
let col = SplitCfg::of(&pp).map_or(col, |c| c.sub_rect(col, 0));
let spacing = spacing_for(&pp, base_chars.size);
let spacing = spacing_for(&pp, base_chars.size, story);
let mut lines: Vec<(std::ops::Range<usize>, String)> = Vec::new();
for _ in 0..3 {
// The same drop cap, spacing, hyphenation and breaker as the layout below.
@@ -1292,7 +1292,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice));
let base_size = base_chars.size;
let spacing = spacing_for(&pp, base_size);
let spacing = spacing_for(&pp, base_size, story);
// An empty paragraph takes its height from its paragraph mark's character format.
let mark_chars = if glyphs.is_empty() {
std::borrow::Cow::Owned(doc.styles.resolve_char(&base_chars, story.char_format_of(prange.start)))
@@ -2303,8 +2303,22 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio
out
}
/// Optical margin alignment for a paragraph of `story`: (on, the size the hang is set for).
/// Ignore Optical Margin turns it off; the story's setting, when on, applies to every other
/// paragraph; otherwise the paragraph's own switch hangs at each glyph's own size.
fn optical_for(pp: &ParaProps, story: &Story) -> (bool, Option<f64>) {
if pp.ignore_optical_margin {
return (false, None);
}
match story.optical_margin.hang_size() {
Some(size) => (true, Some(size)),
None => (pp.optical_margin, None),
}
}
/// Breaker parameters from the paragraph's settings.
fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
fn spacing_for(pp: &ParaProps, base_size: f64, story: &Story) -> Spacing {
let (optical, optical_size) = optical_for(pp, story);
Spacing {
justify: pp.align.is_justified(),
word_min: pp.word_space_min,
@@ -2322,7 +2336,8 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
// The zone is the Single-line Composer's (unjustified text only), as in InDesign: the
// Paragraph Composer weighs every break of the paragraph instead.
hyph_zone: if pp.align.is_justified() || !pp.composer.single_line() { 0.0 } else { pp.hyph_zone },
optical: pp.optical_margin,
optical,
optical_size,
korean_char_breaks: pp.korean_char_breaks,
kinsoku_priority: match pp.kinsoku_type.as_str() {
"KinsokuPushInFirst" => 1,
@@ -3448,8 +3463,8 @@ fn layout_line(
if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab {
let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space());
let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space());
let hl = if sp.optical { first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv) } else { 0.0 };
let optical_right = if sp.optical { lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv) } else { 0.0 };
let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp));
let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp));
let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang);
let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular {
cjk_right.min((line.iter().map(|g| g.adv).sum::<f64>() - measure).max(0.0))
+1 -1
View File
@@ -130,7 +130,7 @@ impl Notes {
self.num = o.start_at;
}
self.numbers[k] = self.num;
self.num += 1;
self.num = self.num.saturating_add(1);
sub.notes.insert(self.refs[k], o.label(self.numbers[k], false));
}
sub.note_position = o.ref_position;
+77 -9
View File
@@ -9,9 +9,42 @@ use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil};
use crate::grep::{Grep, GrepOptions};
/// Patterns kept compiled at once. A document's GREP styles are few; the cache grew without
/// bound across every pattern a session ever composed (#1598).
const MAX_CACHED_PATTERNS: usize = 64;
thread_local! {
/// Compiled GREP styles by pattern; None for one that doesn't compile or gave up on a search.
static PATTERNS: RefCell<HashMap<String, Option<Grep>>> = RefCell::new(HashMap::new());
/// Compiled GREP styles by pattern, with the tick they were last used at; `None` for one that
/// doesn't compile or gave up on a search. Bounded to [`MAX_CACHED_PATTERNS`], least recently
/// used evicted first.
static PATTERNS: RefCell<HashMap<String, (Option<Grep>, u64)>> = RefCell::new(HashMap::new());
/// Counts cache uses, so the least recently used entry can be found.
static TICK: RefCell<u64> = const { RefCell::new(0) };
}
/// The compiled pattern for `pattern`, compiling it on first use and evicting the least recently
/// used entry once the cache is full. `f` is given `None` when the pattern does not compile.
fn with_pattern<R>(pattern: &str, f: impl FnOnce(&mut Option<Grep>) -> R) -> R {
let now = TICK.with(|t| {
let mut t = t.borrow_mut();
*t += 1;
*t
});
PATTERNS.with(|p| {
let mut p = p.borrow_mut();
if !p.contains_key(pattern)
&& p.len() >= MAX_CACHED_PATTERNS
&& let Some(oldest) = p.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone())
{
p.remove(&oldest);
}
let slot = p.entry(pattern.to_string()).or_insert_with(|| {
let re = Grep::new(pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {pattern:?} skipped: {e}")).ok();
(re, now)
});
slot.1 = now;
f(&mut slot.0)
})
}
fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) -> bool {
@@ -71,14 +104,15 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
if g.style.is_empty() || g.pattern.is_empty() {
continue;
}
PATTERNS.with(|p| {
let mut p = p.borrow_mut();
let slot = p.entry(g.pattern.clone()).or_insert_with(|| {
Grep::new(&g.pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {:?} skipped: {e}", g.pattern)).ok()
});
with_pattern(&g.pattern, |slot| {
let Some(re) = slot else { return };
match re.find_all(text.get(range.start..end).unwrap_or(""), usize::MAX) {
Ok(ms) => out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))),
match re.find_nonempty(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) {
Ok(ms) => {
if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH {
log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len());
}
out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone())));
}
Err(e) => {
log::warn!("GREP style {:?} disabled: {e}", g.pattern);
*slot = None;
@@ -93,6 +127,40 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
mod tests {
use super::*;
/// A pattern that can match nothing still styles a paragraph longer than the cap: empty
/// matches don't count toward it.
#[test]
fn empty_matches_do_not_use_up_the_grep_cap() {
let text = format!("{}a", "b".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 100));
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a*".into() }], 0);
assert_eq!(o.len(), 1, "the one non-empty match at the end is styled");
}
/// A GREP style's scan is bounded two ways (#1598): the matches one paragraph can take, and
/// the patterns kept compiled at once.
#[test]
fn a_grep_scan_and_the_pattern_cache_are_bounded() {
// More matches in one paragraph than the cap: the scan stops with what it has instead of
// paying the backtrack limit again for each one.
let text = "a ".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 500);
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a".into() }], 0);
assert_eq!(o.len(), crate::grep::MAX_MATCHES_PER_PARAGRAPH, "the scan stops at the cap");
// The cache holds at most MAX_CACHED_PATTERNS, and the one just used is not the one
// dropped: a document's own styles stay compiled however many patterns a session saw.
PATTERNS.with(|p| p.borrow_mut().clear());
for i in 0..MAX_CACHED_PATTERNS + 20 {
let pattern = format!("x{i}y");
overlays("x1y", 0..3, &[], &[GrepStyle { style: "S".into(), pattern }], 0);
}
let (len, has_last) = PATTERNS.with(|p| {
let p = p.borrow();
(p.len(), p.contains_key(&format!("x{}y", MAX_CACHED_PATTERNS + 19)))
});
assert_eq!(len, MAX_CACHED_PATTERNS, "the cache is bounded");
assert!(has_last, "the most recently used pattern is still cached");
}
#[test]
fn nested_and_grep_ranges() {
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
+50
View File
@@ -1358,6 +1358,56 @@ fn optical_margin_hangs_punctuation() {
assert!(lines.iter().all(|l| l.end_x <= l.x1 + 12.0));
}
/// How far the opening quote of a one-line paragraph at `text_size` hangs into the left margin,
/// with the story's Optical Margin Alignment `story` (on, size) and the paragraph's attributes.
fn quote_hang(text_size: f64, story: Option<f64>, para: ParaAttrs) -> f64 {
let text = "“Quoted” words.";
let first_x = |story: Option<f64>, para: ParaAttrs| {
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 400.0, 200.0), para);
let st = d.story_mut(sid).unwrap();
st.format_chars(0..text.len(), |f| f.over.size = Some(text_size));
if let Some(size) = story {
st.optical_margin = designcraft_doc::OpticalMargin { on: true, size };
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert_eq!(lines.len(), 1);
lines[0].glyphs[0].x
};
first_x(None, ParaAttrs::default()) - first_x(story, para)
}
#[test]
fn story_optical_margin_hang_scales_with_its_size() {
let at12 = quote_hang(12.0, Some(12.0), ParaAttrs::default());
let at24 = quote_hang(12.0, Some(24.0), ParaAttrs::default());
assert!(at12 > 1.0, "{at12}");
assert!((at24 - 2.0 * at12).abs() < 0.01, "{at12} {at24}");
// The size is the type size the hang is set for: 24 pt text at 24 hangs like 12 at 12, twice as far.
let big = quote_hang(24.0, Some(24.0), ParaAttrs::default());
assert!((big - 2.0 * at12).abs() < 0.01, "{at12} {big}");
}
#[test]
fn ignore_optical_margin_paragraph_does_not_hang() {
let ignored = ParaAttrs { ignore_optical_margin: Some(true), optical_margin: Some(true), ..Default::default() };
assert_eq!(quote_hang(12.0, Some(12.0), ignored.clone()), 0.0);
assert_eq!(quote_hang(12.0, None, ignored), 0.0);
}
#[test]
fn paragraph_optical_margin_hangs_at_the_text_size() {
// Documents saved before the story setting existed: the paragraph's switch hangs each
// character by its own size, not the story's default 12 pt.
let own = ParaAttrs { optical_margin: Some(true), ..Default::default() };
let at24 = quote_hang(24.0, None, own.clone());
assert!((at24 - quote_hang(24.0, Some(24.0), ParaAttrs::default())).abs() < 0.01);
assert!((at24 - 2.0 * quote_hang(12.0, None, own)).abs() < 0.01);
// The story setting, when on, sets the size.
let story = quote_hang(24.0, Some(12.0), ParaAttrs { optical_margin: Some(true), ..Default::default() });
assert!((story - at24 / 2.0).abs() < 0.01, "{story} {at24}");
}
// ---------- golden paragraph corpus ----------
/// Original prose (written for this corpus) with a realistic mix of short and long words.
+34
View File
@@ -650,6 +650,34 @@ pub const MAX_TAB_LEADER: usize = 8;
/// Stops closer than this share a position.
pub const SAME_TAB_POSITION: f64 = 0.01;
/// Smallest type size the engine accepts, in points.
pub const MIN_TYPE_SIZE: f64 = 0.1;
/// Largest type size the engine accepts, in points, as InDesign's own field allows.
pub const MAX_TYPE_SIZE: f64 = 1296.0;
/// Largest measure an imported indent, spacing or offset can carry, in points: the furthest tab
/// stop, which is the widest page.
pub const MAX_MEASURE: f64 = MAX_TAB_POSITION;
/// A type size read from a file, or `None` when it is not a size the engine can mean.
///
/// A file can hold any number, including `NaN`, `inf`, `0`, a negative or `1e300`. Every importer
/// had its own spelling of this test, or only checked that the value was finite -- so tagged text
/// took `<cSize:1e300>` and set a 1e300 pt size on the text (#1593). A size outside the range is
/// dropped rather than clamped, so the text keeps the size its style gives it.
pub fn imported_type_size(pt: f64) -> Option<f64> {
(pt.is_finite() && (MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&pt)).then_some(pt)
}
/// An indent, spacing or offset read from a file, clamped to ±[`MAX_MEASURE`]; `None` when the
/// value is not finite.
///
/// Unlike a type size this is clamped rather than dropped: a file that says "indent everything"
/// still indents to the widest page the engine has, where dropping the value would quietly
/// restore the style's own indent and hide what the file asked for (#1593).
pub fn imported_measure(pt: f64) -> Option<f64> {
pt.is_finite().then(|| pt.clamp(-MAX_MEASURE, MAX_MEASURE))
}
impl TabStop {
/// A finite position clamped to `0..=MAX_TAB_POSITION`.
pub fn clamp_position(v: f64) -> Result<f64, String> {
@@ -1274,7 +1302,13 @@ attr_set! {
/// again with the list (an indent the paragraph had before stays).
list_hang: bool = false,
balance_ragged: bool = false,
/// Hang punctuation outside the measure in this paragraph, at each character's own size
/// (DesignCraft's own per-paragraph switch; a story with Optical Margin Alignment on
/// overrides it with the story's size).
optical_margin: bool = false,
/// Ignore Optical Margin: no hang in this paragraph, whatever the story or
/// `optical_margin` say.
ignore_optical_margin: bool = false,
/// None uses the legacy built-in rules; an empty set explicitly disables kinsoku.
kinsoku: Option<crate::cjk::Kinsoku> = None => "deserialize_explicit_value",
/// Preserved spacing-table reference; compatibility audit reports unsupported composition.
+12 -8
View File
@@ -318,15 +318,16 @@ impl Document {
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let after = other.frames.split_off(pos_to);
let before = std::mem::take(&mut other.frames);
let (vertical, direction) = (other.vertical, other.direction);
let (vertical, direction, optical_margin) = (other.vertical, other.direction, other.optical_margin);
let offset;
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) };
// The story keeps its own direction, even when it was empty and takes the other's text.
let own = (st.vertical, st.direction);
// The story keeps its own direction and options, even when it was empty and takes the
// other's text.
let own = (st.vertical, st.direction, st.optical_margin);
st.append_story(other);
(st.vertical, st.direction) = own;
(st.vertical, st.direction, st.optical_margin) = own;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
@@ -345,6 +346,7 @@ impl Document {
ns.frames = before.clone();
ns.vertical = vertical;
ns.direction = direction;
ns.optical_margin = optical_margin;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
@@ -526,12 +528,12 @@ impl Document {
/// stays with the first part and may become overset).
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (tail, vertical, direction) = {
let (tail, vertical, direction, optical_margin) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?;
let tail = st.frames.split_off(p + 1);
st.rev = st.rev.wrapping_add(1);
(tail, st.vertical, st.direction)
(tail, st.vertical, st.direction, st.optical_margin)
};
if tail.is_empty() {
return Ok(());
@@ -541,6 +543,7 @@ impl Document {
ns.frames = tail.clone();
ns.vertical = vertical;
ns.direction = direction;
ns.optical_margin = optical_margin;
self.stories.insert(nid, Arc::new(ns));
for f in tail {
if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) {
@@ -554,20 +557,21 @@ impl Document {
/// `frame` stays on the page with a new empty story.
pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (vertical, direction) = {
let (vertical, direction, optical_margin) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
if st.frames.len() < 2 {
return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame")));
}
st.frames.retain(|f| *f != frame);
st.rev = st.rev.wrapping_add(1);
(st.vertical, st.direction)
(st.vertical, st.direction, st.optical_margin)
};
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = vec![frame];
ns.vertical = vertical;
ns.direction = direction;
ns.optical_margin = optical_margin;
self.stories.insert(nid, Arc::new(ns));
let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?;
t.story = nid;
+2 -2
View File
@@ -147,7 +147,7 @@ impl crate::Document {
if st.id == sid {
break;
}
n += st.endnotes.len() as u32;
n = n.saturating_add(st.endnotes.len() as u32);
}
n
}
@@ -173,7 +173,7 @@ impl crate::Document {
let at = out.len();
out.insert_with(at, &note.text.text[r].replace('\n', " "), f.clone());
}
n += 1;
n = n.saturating_add(1);
}
}
out
+1 -1
View File
@@ -262,7 +262,7 @@ impl crate::Document {
let Some(me) = self.story(sid) else { return o.start_at };
let mine = key(me);
let before: usize = self.stories.values().filter(|st| st.id != sid && !st.notes.is_empty() && key(st) < mine).map(|st| st.notes.len()).sum();
o.start_at + before as u32
o.start_at.saturating_add(before as u32)
}
/// Absolute page an item sits on (its center), None on parent pages / pasteboard-only spreads.
+59
View File
@@ -125,6 +125,9 @@ pub struct Story {
/// right to left, in every frame of the thread.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub vertical: bool,
/// Story ▸ Optical Margin Alignment.
#[serde(default, skip_serializing_if = "OpticalMargin::is_default")]
pub optical_margin: OpticalMargin,
/// Bumped on every edit (composition cache key).
#[serde(default)]
pub rev: u64,
@@ -160,6 +163,45 @@ pub struct Story {
pub file_link: Option<TextFileLink>,
}
/// Story ▸ Optical Margin Alignment: punctuation and the edges of some letters hang outside the
/// frame's edges, in every paragraph of the story except those with Ignore Optical Margin.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct OpticalMargin {
pub on: bool,
/// The type size (points) the hang amounts are set for: text of this size hangs by the
/// built-in proportions of each character's advance, and the hang grows with this size.
pub size: f64,
}
impl Default for OpticalMargin {
fn default() -> Self {
OpticalMargin { on: false, size: Self::DEFAULT_SIZE }
}
}
impl OpticalMargin {
pub const DEFAULT_SIZE: f64 = 12.0;
/// InDesign's range for the size.
pub const MIN_SIZE: f64 = 0.1;
pub const MAX_SIZE: f64 = 1296.0;
/// `v` clamped to the size range, or `None` when it isn't a finite number.
pub fn clamp_size(v: f64) -> Option<f64> {
v.is_finite().then(|| v.clamp(Self::MIN_SIZE, Self::MAX_SIZE))
}
/// The size the story's text hangs for, when the setting is on (a stored size outside the
/// range is clamped, a non-finite one reads as the default).
pub fn hang_size(&self) -> Option<f64> {
self.on.then(|| Self::clamp_size(self.size).unwrap_or(Self::DEFAULT_SIZE))
}
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
/// A linked text file: where a placed story came from, what the file held, and the Word Import
/// Options that brought it in, so Update Link can import it again the same way.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -201,6 +243,7 @@ impl Story {
frames: vec![],
direction: crate::TextDirection::LeftToRight,
vertical: false,
optical_margin: OpticalMargin::default(),
rev: 0,
tables: BTreeMap::new(),
notes: Vec::new(),
@@ -596,6 +639,22 @@ mod tests {
Story::with_text(StoryId(1), t, ParaFormat::default())
}
#[test]
fn optical_margin_is_optional_in_saved_stories() {
let plain = serde_json::to_value(st("Text")).unwrap();
assert!(plain.get("opticalMargin").is_none());
let old: Story = serde_json::from_value(plain).unwrap();
assert_eq!(old.optical_margin, OpticalMargin { on: false, size: 12.0 });
let mut s = st("Text");
s.optical_margin = OpticalMargin { on: true, size: 18.0 };
let back: Story = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
assert_eq!(back.optical_margin, s.optical_margin);
// A hostile stored size is clamped where it is used.
assert_eq!(OpticalMargin { on: true, size: 1e9 }.hang_size(), Some(OpticalMargin::MAX_SIZE));
assert_eq!(OpticalMargin { on: true, size: -3.0 }.hang_size(), Some(OpticalMargin::MIN_SIZE));
assert_eq!(OpticalMargin { on: false, size: 18.0 }.hang_size(), None);
}
#[test]
fn paragraphs_track_newlines() {
let mut s = st("one\ntwo");
+220 -2
View File
@@ -2,11 +2,11 @@
use std::sync::Arc;
use designcraft_doc::{EditorialNote, NOTE_MARK, StoryId};
use designcraft_doc::{ANCHOR_MARK, ENDNOTE_REF, EditorialNote, FOOTNOTE_REF, INDEX_MARK, NOTE_MARK, OBJECT_MARK, StoryId, TABLE_ANCHOR, XREF_MARK};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param};
use crate::Result;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -37,6 +37,15 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"story": ts.story.0, "id": id}))
})
}),
cmd!(
"note.convertFromText",
"Convert to Note",
["Type", "Notes"],
None,
"{author?} → {story, id}. The selected text moves into a new note anchored where it was.",
has_text_range,
convert_from_text
),
cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| {
let (sid, id) = target(p)?;
let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string));
@@ -93,9 +102,76 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(Value::Array(out))
}),
cmd!(
noundo "note.previous",
"Previous Note",
["Type", "Notes"],
None,
"{} → {story, id, pos}. The caret moves after the anchor of the story's previous note.",
has_previous,
|s, _| go_to(s, false)
),
cmd!(
noundo "note.next",
"Next Note",
["Type", "Notes"],
None,
"{} → {story, id, pos}. The caret moves after the anchor of the story's next note.",
has_next,
|s, _| go_to(s, true)
),
]
}
/// Characters whose payload lives outside the story text: a note made from text that holds one
/// would lose it.
const MARKS: [char; 8] = [NOTE_MARK, ENDNOTE_REF, FOOTNOTE_REF, OBJECT_MARK, INDEX_MARK, ANCHOR_MARK, XREF_MARK, TABLE_ANCHOR];
fn has_text_range(s: &Session) -> std::result::Result<(), String> {
has_text(s)?;
match s.active().and_then(|d| d.selection.text) {
Some(t) if t.cell.is_some() => Err("notes go in story text".into()),
Some(t) if t.is_caret() => Err("select the text to convert".into()),
_ => Ok(()),
}
}
/// Type › Notes › Convert to Note: the selected text leaves the story and becomes the text of a
/// new note, whose anchor takes the text's place.
fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "note.convertFromText";
let ts = s.doc()?.selection.text.ok_or_else(|| bad(ID, "select the text to convert"))?;
if ts.cell.is_some() {
return Err(bad(ID, "notes go in story text"));
}
let r = ts.range();
if r.is_empty() {
return Err(bad(ID, "select the text to convert"));
}
let author = str_param(p, "author").unwrap_or("").to_string();
s.edit(|d, sel| {
let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?;
let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string();
if text.contains(MARKS) {
return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker"));
}
let fmt = story.format_after(r.start).clone();
story.delete(r.clone());
story.insert_with(r.start, &NOTE_MARK.to_string(), fmt);
story.fix_marks();
let k = story.text[..r.start].matches(NOTE_MARK).count();
let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?;
let n = Arc::make_mut(n);
(n.text, n.author) = (text, author);
let id = n.id;
if let Some(t) = &mut sel.text {
let c = r.start + NOTE_MARK.len_utf8();
(t.anchor, t.focus) = (c, c);
}
Ok(json!({"story": ts.story.0, "id": id}))
})
}
fn target(p: &Value) -> Result<(StoryId, u64)> {
let sid = p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("note", "`story` required"))?;
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("note", "`id` required"))?;
@@ -108,6 +184,53 @@ fn anchor(st: &designcraft_doc::Story, id: u64) -> Option<usize> {
st.text.match_indices(NOTE_MARK).nth(k).map(|(i, _)| i)
}
/// The anchor offset and id of the note before or after the selection `t` in its story. Going
/// back skips the note whose anchor the caret directly follows, so repeated steps reach every note.
fn neighbor(st: &designcraft_doc::Story, t: &designcraft_doc::TextSel, forward: bool) -> Option<(usize, u64)> {
let r = t.range();
let mut anchors = st.text.match_indices(NOTE_MARK).enumerate().map(|(k, (i, _))| (k, i));
let (k, at) = if forward { anchors.find(|&(_, i)| i >= r.end) } else { anchors.filter(|&(_, i)| i + NOTE_MARK.len_utf8() < r.start).last() }?;
Some((at, st.editorial.get(k)?.id))
}
fn has_note(s: &crate::Session, forward: bool) -> std::result::Result<(), String> {
has_text(s)?;
match s.active().and_then(|d| Some((d, d.selection.text?))) {
Some((_, t)) if t.cell.is_some() => Err("notes go in story text".into()),
Some((d, t)) if d.doc.story(t.story).and_then(|st| neighbor(st, &t, forward)).is_some() => Ok(()),
_ => Err(no_note(forward).into()),
}
}
fn has_previous(s: &crate::Session) -> std::result::Result<(), String> {
has_note(s, false)
}
fn has_next(s: &crate::Session) -> std::result::Result<(), String> {
has_note(s, true)
}
fn no_note(forward: bool) -> &'static str {
if forward { "no later note in this story" } else { "no earlier note in this story" }
}
/// Type › Notes › Previous Note and Next Note: from the insertion point, the caret moves after
/// the anchor of the story's previous or next note.
fn go_to(s: &mut crate::Session, forward: bool) -> Result<Value> {
let id = if forward { "note.next" } else { "note.previous" };
let st = s.doc_mut()?;
let t = st.selection.text.ok_or_else(|| bad(id, "place the insertion point in text"))?;
if t.cell.is_some() {
return Err(bad(id, "notes go in story text"));
}
let (at, note) = st.doc.story(t.story).and_then(|x| neighbor(x, &t, forward)).ok_or_else(|| bad(id, no_note(forward)))?;
let pos = at + NOTE_MARK.len_utf8();
st.selection.text = Some(designcraft_doc::TextSel::caret(t.story, pos));
st.bump_revision();
s.typing_format = None;
Ok(json!({"story": t.story.0, "id": note, "pos": pos}))
}
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -136,6 +259,46 @@ mod tests {
assert!(s.doc().unwrap().doc.stories[&sid].editorial.is_empty());
}
#[test]
fn previous_and_next_note_step_through_the_story_s_notes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two three."})).unwrap();
let len = designcraft_doc::NOTE_MARK.len_utf8();
let caret = |s: &mut Session, at: usize| s.execute("text.select", &json!({"story": r["story"], "anchor": at, "focus": at})).unwrap();
caret(&mut s, 3);
let first = s.execute("note.new", &json!({"text": "a"})).unwrap();
caret(&mut s, 7 + len);
let second = s.execute("note.new", &json!({"text": "b"})).unwrap();
caret(&mut s, 0);
let disabled = |s: &mut Session, id: &str| matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..)));
assert!(disabled(&mut s, "note.previous"), "no note before the start");
let n = s.execute("note.next", &json!({})).unwrap();
assert_eq!((n["id"].clone(), n["pos"].clone()), (first["id"].clone(), json!(3 + len)));
let n = s.execute("note.next", &json!({})).unwrap();
assert_eq!((n["id"].clone(), n["pos"].clone()), (second["id"].clone(), json!(7 + 2 * len)));
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (7 + 2 * len, 7 + 2 * len), "the caret follows the anchor");
assert!(disabled(&mut s, "note.next"), "no note after the last");
// From just after the second anchor, Previous Note goes to the first one.
let n = s.execute("note.previous", &json!({})).unwrap();
assert_eq!(n["id"], first["id"]);
assert!(disabled(&mut s, "note.previous"));
// From the middle of the story, each direction finds its neighbor.
caret(&mut s, 5 + len);
assert_eq!(s.execute("note.previous", &json!({})).unwrap()["id"], first["id"]);
caret(&mut s, 5 + len);
assert_eq!(s.execute("note.next", &json!({})).unwrap()["id"], second["id"]);
// Formatting set at the insertion point does not survive the move.
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
assert!(s.typing_format.is_some());
s.execute("note.previous", &json!({})).unwrap();
assert!(s.typing_format.is_none(), "note.previous");
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
s.execute("note.next", &json!({})).unwrap();
assert!(s.typing_format.is_none(), "note.next");
}
#[test]
fn notes_insert_into_a_story_whose_markers_lack_payloads() {
let mut s = Session::new();
@@ -159,4 +322,59 @@ mod tests {
assert_eq!(st.endnotes[1].text.text, "An endnote.");
assert_eq!(st.notes[1].text.text, "A footnote.");
}
#[test]
fn selected_text_converts_to_a_note_at_its_place() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two\nthree four."})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let mark = designcraft_doc::NOTE_MARK.len_utf8();
s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap();
s.execute("note.new", &json!({"text": "earlier"})).unwrap();
// "two\nthree " across the paragraph return, selected backwards.
let (a, b) = (3 + mark + 1, 3 + mark + 1 + "two\nthree ".len());
s.execute("text.select", &json!({"story": r["story"], "anchor": b, "focus": a})).unwrap();
let n = s.execute("note.convertFromText", &json!({"author": "Ed"})).unwrap();
let st = &s.doc().unwrap().doc.stories[&sid];
st.check().unwrap();
assert_eq!(st.text, format!("One{m} {m}four.", m = designcraft_doc::NOTE_MARK));
assert_eq!(st.paras.len(), 1, "the paragraph return went into the note");
let list = s.execute("note.list", &json!({})).unwrap();
assert_eq!(list[0]["text"], "earlier");
assert_eq!((list[1]["id"].clone(), list[1]["at"].clone()), (n["id"].clone(), json!(a)));
assert_eq!((list[1]["text"].as_str(), list[1]["author"].as_str()), (Some("two\nthree "), Some("Ed")));
let caret = s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus));
assert_eq!(caret, Some((a + mark, a + mark)), "the insertion point follows the anchor");
s.execute("note.convertToText", &json!({"story": n["story"], "id": n["id"]})).unwrap();
assert_eq!(s.doc().unwrap().doc.stories[&sid].text, format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let st = &s.doc().unwrap().doc.stories[&sid];
assert_eq!((st.text.as_str(), st.editorial.len()), (format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK).as_str(), 1));
}
#[test]
fn convert_to_note_needs_selected_story_text_without_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap();
let disabled = |r| matches!(r, Err(crate::EngineError::Disabled(..)));
assert!(disabled(s.execute("note.convertFromText", &json!({}))), "an insertion point selects no text");
s.execute("note.new", &json!({"text": "keep"})).unwrap();
let before = s.doc().unwrap().doc.stories[&sid].clone();
let all = before.len();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": all})).unwrap();
let refused = s.execute("note.convertFromText", &json!({}));
assert!(matches!(refused, Err(crate::EngineError::BadParams { .. })), "a note inside the selection would be lost");
assert_eq!(s.doc().unwrap().doc.stories[&sid], before);
s.execute("text.select", &json!({"story": r["story"], "anchor": all, "focus": all})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("text.insert", &json!({"text": "cell", "raw": true})).unwrap();
s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap();
assert!(s.doc().unwrap().selection.text.is_some_and(|t| t.cell.is_some() && !t.is_caret()));
assert!(disabled(s.execute("note.convertFromText", &json!({}))), "notes go in story text");
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
for st in d.stories.values().filter(|st| !st.endnotes.is_empty()) {
let first = d.endnote_start(st.id);
for (k, n) in st.endnotes.iter().enumerate() {
out.push(json!({"story": st.id.0, "id": n.id, "number": first + k as u32, "text": n.text.text}));
out.push(json!({"story": st.id.0, "id": n.id, "number": first.saturating_add(k as u32), "text": n.text.text}));
}
}
out.sort_by_key(|v| v["number"].as_u64());
+41 -1
View File
@@ -396,8 +396,23 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi
#[cfg(not(target_arch = "wasm32"))]
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (u32, u32))> {
reload_capped(path, super::interchange::MAX_LINKED_FILE_BYTES)
}
/// A linked graphic read again from disk, and its pixel size, reading at most `cap` bytes.
///
/// How large a linked file is belongs to whoever wrote the document, not to whoever opens it: the
/// path comes out of the document, and Update, Relink and Relink Folder all follow it. An
/// unbounded `read` of a link pointing at a multi-GB file exhausted memory (#1596). The cap is
/// the one packaging already applies to a linked file, and a file over it is refused by name
/// rather than read.
#[cfg(not(target_arch = "wasm32"))]
fn reload_capped(path: impl AsRef<Path>, cap: u64) -> Result<(Vec<u8>, (u32, u32))> {
let path = path.as_ref();
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
let b = crate::io::read_capped(path, cap).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
if b.len() as u64 > cap {
return Err(bad("links", format!("{}: the linked file is larger than {} MB", path.display(), cap / (1024 * 1024))));
}
if designcraft_render::is_pdf(&b) {
designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?;
}
@@ -584,6 +599,31 @@ mod tests {
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
}
/// A linked file's size is the document's choice, so a link is read under a cap (#1596).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_linked_file_over_the_cap_is_refused_instead_of_read() {
let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.png");
let bytes = png(40, 40);
std::fs::write(&path, &bytes).unwrap();
// Under the cap the file is read and measured as before.
let (read, px) = reload_capped(&path, bytes.len() as u64).expect("a file within the cap is read");
assert_eq!((read.len(), px), (bytes.len(), (40, 40)));
// Over it the file is refused by name, and the error says what the limit is.
let err = reload_capped(&path, (bytes.len() - 1) as u64).expect_err("a file over the cap is refused");
let msg = err.to_string();
assert!(msg.contains("big.png") && msg.contains("larger than"), "the error names the file and the limit: {msg}");
// The real cap is the one packaging uses, so an ordinary graphic still loads.
assert!(reload(&path).is_ok(), "a normal linked graphic is unaffected");
let _ = std::fs::remove_dir_all(&dir);
}
fn pdf(sizes: &[(u32, u32)]) -> Vec<u8> {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap();
+11
View File
@@ -221,4 +221,15 @@ mod tests {
// Caret is now in footnote text: no nested footnotes.
assert!(s.execute("footnote.insert", &json!({})).is_err());
}
#[test]
fn huge_start_at_does_not_overflow() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap();
s.execute("footnote.insert", &json!({"text": "x"})).unwrap();
s.execute("footnote.options", &json!({"startAt": 4294967295u32})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
assert_eq!(cs.frames[0].notes.len(), 1);
}
}
+29 -1
View File
@@ -584,7 +584,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effects",
[],
None,
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, ids?} — every effect at once, one undo step",
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, globalLight? (the document's global light angle, as an Angle field with Use Global Light sets it), ids?} — every effect at once, one undo step",
has_selection,
effects_all
),
@@ -2293,8 +2293,16 @@ fn effects_all(s: &mut Session, p: &Value) -> Result<Value> {
Some(BlendMode::parse(raw).ok_or_else(|| bad(ID, format!("unknown blend mode `{raw}`")))?)
}
};
// The document's global light angle, changed from an Angle field with Use Global Light on.
let global_light = match p.get("globalLight") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad(ID, "globalLight must be a number"))?),
};
let ids = targets(s, p)?;
s.edit(|d, _| {
if let Some(a) = global_light {
d.settings.global_light = (a + 180.0).rem_euclid(360.0) - 180.0;
}
let mut changed = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
@@ -4145,6 +4153,26 @@ mod light_tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(angle(&s), 45.0);
}
/// The Effects dialog's Angle with Use Global Light on changes the global light, with the
/// effects, in one undo step.
#[test]
fn effects_sets_the_global_light_with_the_effects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.dropShadow", &json!({"on": true, "globalLight": true, "ids": [b]})).unwrap();
let before = s.doc().unwrap().doc.settings.global_light;
s.execute("object.effects", &json!({"ids": [a], "effects": {"dropShadow": {"on": true, "globalLight": true}}, "globalLight": 30})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.settings.global_light, 30.0);
let other = &d.item(ItemId(b)).unwrap().effects.drop_shadow;
assert_eq!(d.light_angle(other.angle, other.global_light), 30.0, "every shadow on Global Light follows");
assert!(s.execute("object.effects", &json!({"ids": [a], "globalLight": "up"})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.global_light, before, "one undo step");
}
}
#[cfg(test)]
+19 -7
View File
@@ -907,13 +907,12 @@ mod tests {
let outside = base.join("separate");
std::fs::create_dir_all(&outside).unwrap();
let redirected = base.join("Package");
match symlink_dir(&outside, &redirected) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let _ = std::fs::remove_dir_all(&base);
return;
}
Err(e) => panic!("could not create directory redirect: {e}"),
// A symbolic link needs Developer Mode or elevation (os error 1314 without them); a
// directory junction needs neither, and `is_redirect` treats both as redirects.
if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) {
eprintln!("skipped: directory redirects can't be made here");
let _ = std::fs::remove_dir_all(&base);
return;
}
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
@@ -922,4 +921,17 @@ mod tests {
std::fs::remove_dir(&redirected).unwrap();
let _ = std::fs::remove_dir_all(&base);
}
/// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made.
#[cfg(windows)]
fn make_junction(target: &Path, link: &Path) -> bool {
use std::os::windows::process::CommandExt;
// Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd.
std::process::Command::new("cmd")
.args(["/D", "/C"])
.raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display()))
.output()
.is_ok_and(|output| output.status.success())
}
}
+6 -2
View File
@@ -354,11 +354,15 @@ mod placed_vector_tests {
s.execute("file.place", &json!({"base64": base64_encode(&png), "name": "photo.jpg", "x": 72, "y": 72, "width": 20})).unwrap();
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let asset = doc.assets.values_mut().next().unwrap();
std::sync::Arc::make_mut(asset).link = Some("/definitely/missing dir/photo.jpg".into());
// The temporary folder is an absolute path with the default settings on Windows, macOS and Linux. On Windows,
// `/x` names no drive, so `document_path` refuses it when the document has no path.
let link = std::env::temp_dir().join(format!("dc-preflight-missing-{} dir", std::process::id())).join("photo.jpg");
let link = link.to_string_lossy().into_owned();
std::sync::Arc::make_mut(asset).link = Some(link.clone());
let r = s.execute("preflight.run", &json!({})).unwrap();
let missing: Vec<&str> =
r["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingLink").map(|i| i["message"].as_str().unwrap()).collect();
assert_eq!(missing, ["Missing link: /definitely/missing dir/photo.jpg"]);
assert_eq!(missing, [format!("Missing link: {link}")]);
}
}
+2 -2
View File
@@ -302,7 +302,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Paragraph Style…",
[],
None,
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, ignoreOpticalMargin, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
has_doc,
create_para
),
@@ -311,7 +311,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Style Options…",
[],
None,
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
has_doc,
edit_para
),
+20 -1
View File
@@ -1765,7 +1765,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: O
ts.borders = Default::default();
}
if let Some(a) = p.get("altRows").and_then(Value::as_object) {
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32);
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX));
let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string();
let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32);
ts.alt_rows = Some(designcraft_doc::AltFills {
@@ -2480,3 +2480,22 @@ mod resize_border_tests {
assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err());
}
}
#[cfg(test)]
mod style_alt_rows_tests {
use serde_json::json;
use super::table_style_fields;
#[test]
fn style_alt_rows_counts_saturate() {
let mut ts = designcraft_doc::TableStyle::default();
let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}});
table_style_fields(&mut ts, &p, None);
let a = ts.alt_rows.unwrap();
assert_eq!(a.first, u32::MAX, "first");
assert_eq!(a.next, u32::MAX, "next");
assert_eq!(a.skip_first, u32::MAX, "skip_first");
assert_eq!(a.skip_last, 3, "skip_last");
}
}
+164 -5
View File
@@ -104,6 +104,15 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"story.options",
"Story Options",
[],
None,
"{story?, frame?: a text frame of the story (default: the stories of the text selection or the selected frames), opticalMargin?: bool — Optical Margin Alignment: punctuation and letter edges hang outside the frame in every paragraph of the story except those with ignoreOpticalMargin, opticalMarginSize?: pt (0.1–1296, default 12) — the type size the hang amounts are set for} → {stories}",
has_doc,
story_options
),
cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
let text = super::text_param(p, "text");
@@ -182,7 +191,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
@@ -374,7 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Selection Attributes",
[],
None,
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)",
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)",
has_doc,
selection_attrs
),
@@ -991,11 +1000,11 @@ fn sentence_bounds(s: &str, i: usize) -> (usize, usize) {
(pa + start, pb)
}
/// Type ▸ Story Direction.
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
/// The stories of the text selection, the selected cells and the selected text frames.
fn selected_stories(s: &Session) -> Result<Vec<StoryId>> {
let st = s.doc()?;
let mut stories: Vec<StoryId> = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect();
stories.dedup();
for id in &st.selection.items {
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame())
&& !stories.contains(&tf.story)
@@ -1003,6 +1012,54 @@ fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
stories.push(tf.story);
}
}
Ok(stories)
}
/// Type ▸ Story: the story-wide options (Optical Margin Alignment and its size).
fn story_options(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "story.options";
let on = match p.get("opticalMargin") {
None => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "`opticalMargin` must be a bool"))?),
};
let size = match p.get("opticalMarginSize") {
None => None,
Some(v) => Some(
v.as_f64()
.and_then(designcraft_doc::OpticalMargin::clamp_size)
.ok_or_else(|| bad(CMD, "`opticalMarginSize` must be a finite number of points"))?,
),
};
if on.is_none() && size.is_none() {
return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize"));
}
let stories = if p.get("story").is_some() || p.get("frame").is_some() {
vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?]
} else {
selected_stories(s)?
};
if stories.is_empty() {
return Err(bad(CMD, "no story: select text or a text frame, or give story or frame"));
}
s.edit(|d, _| {
for sid in &stories {
let st = d.story_mut(*sid).ok_or(designcraft_doc::DocError::NoStory(*sid))?;
let mut om = st.optical_margin;
om.on = on.unwrap_or(om.on);
om.size = size.unwrap_or(om.size);
if om != st.optical_margin {
st.optical_margin = om;
st.rev = st.rev.wrapping_add(1);
}
}
Ok(json!({"stories": stories.len()}))
})
}
/// Type ▸ Story Direction.
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
let stories = selected_stories(s)?;
s.edit(|d, _| {
set_story_direction(d, &stories, vertical);
Ok(json!({"stories": stories.len()}))
@@ -1836,12 +1893,30 @@ fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
"paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(),
"charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides,
"selectedText": selected.trim()});
let mut stories = selected_stories(s)?;
if stories.is_empty() {
stories.push(sid);
}
out["storyOptions"] = story_options_of(&st.doc, &stories);
if bool_or(p, "mixed", false) {
out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets));
}
Ok(out)
}
/// The story options of `stories`, as story.options names them; a value that differs between
/// the stories is null.
fn story_options_of(doc: &designcraft_doc::Document, stories: &[StoryId]) -> Value {
let mut values = stories.iter().filter_map(|sid| doc.story(*sid)).map(|st| st.optical_margin);
let Some(first) = values.next() else { return json!({"opticalMargin": null, "opticalMarginSize": null}) };
let (mut on, mut size) = (Some(first.on), Some(first.size));
for om in values {
on = on.filter(|v| *v == om.on);
size = size.filter(|v| *v == om.size);
}
json!({"opticalMargin": on, "opticalMarginSize": size})
}
/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`.
fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec<String> {
let mut first: Option<serde_json::Map<String, Value>> = None;
@@ -3280,6 +3355,90 @@ mod bidi_caret_tests {
}
}
#[cfg(test)]
mod story_options_tests {
use super::*;
fn first_x(s: &Session, sid: StoryId) -> f64 {
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
cs.frames[0].lines[0].glyphs[0].x
}
fn story(s: &Session, sid: StoryId) -> designcraft_doc::OpticalMargin {
s.doc().unwrap().doc.story(sid).unwrap().optical_margin
}
#[test]
fn story_options_set_optical_margin_with_one_undo_step_each() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap();
let sid = StoryId(r["story"].as_u64().unwrap());
let flush = first_x(&s, sid);
// The selected frame's story.
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 });
let hung = first_x(&s, sid);
assert!(hung < flush - 1.0, "{hung} {flush}");
s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 24})).unwrap();
assert_eq!(story(&s, sid).size, 24.0);
assert!(first_x(&s, sid) < hung - 1.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 });
s.execute("edit.undo", &json!({})).unwrap();
assert!(!story(&s, sid).on);
assert_eq!(first_x(&s, sid), flush);
// Hostile sizes.
s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 1e9})).unwrap();
assert_eq!(story(&s, sid).size, designcraft_doc::OpticalMargin::MAX_SIZE);
for bad in [json!({"opticalMarginSize": "big"}), json!({"opticalMargin": 1}), json!({}), json!({"story": 999_999, "opticalMargin": true})] {
assert!(s.execute("story.options", &bad).is_err(), "{bad}");
}
}
#[test]
fn ignore_optical_margin_through_type_para_and_paragraph_styles() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap();
let sid = StoryId(r["story"].as_u64().unwrap());
let flush = first_x(&s, sid);
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
assert!(first_x(&s, sid) < flush - 1.0);
let ignored = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.ignore_optical_margin;
s.execute("type.para", &json!({"attrs": {"ignoreOpticalMargin": true}})).unwrap();
assert_eq!(ignored(&s), Some(true));
assert_eq!(first_x(&s, sid), flush);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ignored(&s), None);
s.execute("style.paragraph.create", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": true}})).unwrap();
let resolved = |s: &Session| s.doc().unwrap().doc.styles.resolve_para_style("Flush").0.ignore_optical_margin;
assert!(resolved(&s));
s.execute("style.paragraph.edit", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": false}})).unwrap();
assert!(!resolved(&s));
s.execute("edit.undo", &json!({})).unwrap();
assert!(resolved(&s));
s.execute("style.paragraph.apply", &json!({"name": "Flush"})).unwrap();
assert_eq!(first_x(&s, sid), flush);
}
#[test]
fn selection_attrs_report_the_story_options_with_differences_as_null() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "One"})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Two"})).unwrap();
let options = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["storyOptions"].clone();
assert_eq!(options(&mut s), json!({"opticalMargin": false, "opticalMarginSize": 12.0}));
s.execute("story.options", &json!({"story": b["story"], "opticalMargin": true, "opticalMarginSize": 18})).unwrap();
assert_eq!(options(&mut s), json!({"opticalMargin": true, "opticalMarginSize": 18.0}), "the selected frame's story");
s.execute("selection.set", &json!({"ids": [a["id"], b["id"]]})).unwrap();
assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": null}));
s.execute("story.options", &json!({"opticalMarginSize": 18})).unwrap();
assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": 18.0}));
}
}
#[cfg(test)]
mod arabic_tests {
use super::*;
+421
View File
@@ -73,9 +73,256 @@ pub fn specs() -> Vec<CommandSpec> {
s.execute("text.insert", &json!({"text": c.to_string()}))
}
),
cmd!(
"variables.convertToText",
"Convert Variable to Text",
["Type", "Text Variables"],
None,
"{} — the variable instances in the selected text become plain text of the value each shows on its page; the variables stay defined",
has_selected_instance,
convert_selection
),
cmd!(
"variables.convertAllToText",
"Convert to Text",
[],
None,
"{name} — every instance of the variable in the document becomes plain text of the value it shows on its page (Text Variables › Convert to Text); the variable stays defined",
has_doc,
convert_all
),
]
}
/// The selected text holds a variable instance (InDesign: select the variable, then Convert
/// Variable to Text). The endnote story is rebuilt from the stories' endnotes, so an edit there
/// would not last.
fn has_selected_instance(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let st = s.active().ok_or("no document")?;
let t = st.selection.text.ok_or("no text selected")?;
let held = st.doc.endnote_story != Some(t.story)
&& st
.doc
.text_story(t.story, t.cell)
.and_then(|x| x.text.get(t.range()))
.is_some_and(|x| x.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()));
if held { Ok(()) } else { Err("no text variable selected".into()) }
}
/// The page story byte `pos` is shown on, which a variable's value depends on: overset text takes
/// the page of the story's last frame; parent pages and the pasteboard have none.
fn shown_page(d: &designcraft_doc::Document, cs: &designcraft_compose::ComposedStory, pos: usize) -> Option<usize> {
designcraft_compose::vars::page_of(d, cs, pos).or_else(|| cs.frames.last().and_then(|f| d.page_of_item(f.frame)))
}
/// Values of every variable on the pages asked for, as composition shows them (running headers
/// included), computed once per page.
struct PageValues<'a> {
doc: &'a designcraft_doc::Document,
running: std::sync::Arc<designcraft_compose::vars::RunningIndex>,
pages: std::collections::HashMap<Option<usize>, Vec<String>>,
}
impl PageValues<'_> {
fn get(&mut self, i: usize, page: Option<usize>) -> String {
let (doc, running) = (self.doc, &self.running);
let v = self.pages.entry(page).or_insert_with(|| designcraft_compose::vars::values(doc, page, Some(running)));
v.get(i).cloned().unwrap_or_default()
}
}
/// Byte offsets of the variable instances in `range` of `text` (of variable `which`, or any),
/// with their variable index.
fn instances(text: &str, range: std::ops::Range<usize>, which: Option<usize>) -> Vec<(usize, usize)> {
let Some(t) = text.get(range.clone()) else { return Vec::new() };
t.char_indices()
.filter_map(|(k, c)| designcraft_doc::vars::var_index(c).filter(|i| which.is_none_or(|w| w == *i)).map(|i| (range.start + k, i)))
.collect()
}
/// Replaces the instances at `at` (byte, variable) in `st` with `value(byte)`, last first so the
/// earlier offsets hold. Returns the change in length before each replaced byte, in text order.
fn replace_instances(st: &mut designcraft_doc::Story, at: &[(usize, usize)], value: &dyn Fn(usize) -> String) -> Vec<(usize, isize)> {
let mut grown = Vec::new();
for &(pos, i) in at.iter().rev() {
let len = var_char(i).map_or(0, char::len_utf8);
let v = value(pos);
st.replace(pos..pos + len, &v);
grown.push((pos, v.len() as isize - len as isize));
}
grown.reverse();
grown
}
/// Moves byte `b` of a story by the growth of the instances replaced before it.
fn shifted(b: usize, grown: &[(usize, isize)]) -> usize {
let d: isize = grown.iter().filter(|(pos, _)| *pos < b).map(|(_, g)| g).sum();
b.saturating_add_signed(d)
}
/// Replaces every instance of variable `i` in `st`, its table cells and its notes with `value`.
/// Returns the growth of `st`'s own text, as [`replace_instances`].
fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: usize) -> Vec<(usize, isize)> {
if depth > MAX_DEPTH {
return Vec::new();
}
let at = instances(&st.text, 0..st.text.len(), Some(i));
let grown = replace_instances(st, &at, &|_| value.to_string());
let mut nested = false;
for t in st.tables.values_mut() {
if t.cells.iter().any(|c| holds(&c.text, i, depth + 1)) {
for c in &mut std::sync::Arc::make_mut(t).cells {
replace_nested(&mut c.text, i, value, depth + 1);
}
nested = true;
}
}
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
if holds(&note.text, i, depth + 1) {
replace_nested(&mut std::sync::Arc::make_mut(note).text, i, value, depth + 1);
nested = true;
}
}
if nested {
st.rev = st.rev.wrapping_add(1);
}
grown
}
/// Whether `st` or text nested in it holds an instance of variable `i`.
fn holds(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool {
depth <= MAX_DEPTH
&& (st.text.chars().any(|c| designcraft_doc::vars::var_index(c) == Some(i))
|| st.tables.values().any(|t| t.cells.iter().any(|c| holds(&c.text, i, depth + 1)))
|| st.notes.iter().chain(st.endnotes.iter()).any(|n| holds(&n.text, i, depth + 1)))
}
/// Byte offset in `st` of the anchor of table `id`.
fn table_anchor(st: &designcraft_doc::Story, id: u64) -> Option<usize> {
let (pi, _) = st.paras.iter().enumerate().find(|(_, p)| p.table == Some(id))?;
let r = st.para_ranges().into_iter().nth(pi)?;
Some(r.start + st.text.get(r)?.find(designcraft_doc::story::TABLE_ANCHOR)?)
}
fn convert_selection(s: &mut Session, _: &Value) -> Result<Value> {
const ID: &str = "variables.convertToText";
let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?;
let range = t.range();
let (at, values) = {
let d = &s.doc()?.doc;
let st = d.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let text = d.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
// Text in a cell or a footnote is on the page of its table's or note's anchor.
let anchor = match t.cell {
None => None,
Some(c) if c.table == designcraft_doc::FOOTNOTE_TABLE => st.note_anchor(c.row as u64),
Some(c) => table_anchor(st, c.table),
};
let cs = s.cache.get(d, t.story, None);
let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() };
let at = instances(&text.text, range.clone(), None);
let values: std::collections::HashMap<usize, String> =
at.iter().map(|&(pos, i)| (pos, pv.get(i, shown_page(d, &cs, anchor.unwrap_or(pos))))).collect();
(at, values)
};
if at.is_empty() {
return Err(bad(ID, "no text variable selected"));
}
s.edit(|d, sel| {
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let grown = replace_instances(st, &at, &|pos| values.get(&pos).cloned().unwrap_or_default());
// The converted text stays selected.
sel.text = Some(designcraft_doc::TextSel { anchor: range.start, focus: shifted(range.end, &grown), ..t });
Ok(json!({"converted": at.len()}))
})
}
/// What converting one story's instances of a variable writes: the value for each instance in
/// its own text, and for the text of each table (by id), footnote and endnote, the value on the
/// page of its anchor.
struct StoryPlan {
sid: designcraft_doc::StoryId,
own: Vec<(usize, String)>,
tables: Vec<(u64, String)>,
notes: Vec<String>,
endnotes: Vec<String>,
}
fn convert_all(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "variables.convertAllToText";
let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?;
let (i, plans) = {
let d = &s.doc()?.doc;
let i = d.variable(name).ok_or_else(|| bad(ID, format!("no variable `{name}`")))?;
let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() };
let mut plans = Vec::new();
for (sid, st) in &d.stories {
if Some(*sid) == d.endnote_story || !holds(st, i, 0) {
continue;
}
let cs = s.cache.get(d, *sid, None);
let mut value = |pos: usize| pv.get(i, shown_page(d, &cs, pos));
let own = instances(&st.text, 0..st.text.len(), Some(i)).into_iter().map(|(pos, _)| (pos, value(pos))).collect();
let tables = st.tables.keys().map(|id| (*id, table_anchor(st, *id).map(&mut value).unwrap_or_default())).collect();
let notes = st.text.match_indices(designcraft_doc::FOOTNOTE_REF).map(|(k, _)| value(k)).collect();
let endnotes = st.text.match_indices(designcraft_doc::ENDNOTE_REF).map(|(k, _)| value(k)).collect();
plans.push(StoryPlan { sid: *sid, own, tables, notes, endnotes });
}
(i, plans)
};
s.edit(|d, sel| {
for plan in &plans {
// A selection in a table cell or a footnote keeps its place in that text: it is
// converted first, so the loop below finds nothing left there.
if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid)
&& let Some(c) = t.cell
{
let value = match c.footnote_id() {
Some(id) => d.story(plan.sid).and_then(|st| st.notes.iter().position(|n| n.id == id)).and_then(|k| plan.notes.get(k)),
None => plan.tables.iter().find(|(id, _)| *id == c.table).map(|(_, v)| v),
};
if let Some(v) = value
&& let Some(text) = d.text_story_mut(plan.sid, Some(c))
{
let grown = replace_nested(text, i, v, 1);
t.anchor = shifted(t.anchor, &grown);
t.focus = shifted(t.focus, &grown);
}
}
let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?;
let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect();
let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default());
let mut nested = false;
for (id, v) in &plan.tables {
if let Some(t) = st.tables.get_mut(id)
&& t.cells.iter().any(|c| holds(&c.text, i, 1))
{
for c in &mut std::sync::Arc::make_mut(t).cells {
replace_nested(&mut c.text, i, v, 1);
}
nested = true;
}
}
for (note, v) in st.notes.iter_mut().zip(&plan.notes).chain(st.endnotes.iter_mut().zip(&plan.endnotes)) {
if holds(&note.text, i, 1) {
replace_nested(&mut std::sync::Arc::make_mut(note).text, i, v, 1);
nested = true;
}
}
if nested {
st.rev = st.rev.wrapping_add(1);
}
// A selection in the story keeps its place in the text.
if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid && t.cell.is_none()) {
t.anchor = shifted(t.anchor, &grown);
t.focus = shifted(t.focus, &grown);
}
}
Ok(Value::Null)
})
}
/// Nesting limit for tables in cells and notes (hostile documents).
const MAX_DEPTH: usize = 32;
@@ -265,6 +512,180 @@ mod tests {
assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it");
}
/// Three pages with "Alpha" in the Heading style on page 1 and "Epsilon" on page 3, a
/// `Chapter Head` running header on Heading, and a text frame on pages 1 and 3 that each hold
/// "<head>|<head>" (two instances). Returns the two frames' stories.
fn running_heads() -> (Session, designcraft_doc::StoryId, designcraft_doc::StoryId) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
let frame = |s: &mut Session, page: usize, y: f64, text: &str| {
let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0;
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, y, 400, y + 60.0], "content": "text", "text": text})).unwrap();
s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story
};
for (page, text) in [(0, "Alpha"), (2, "Epsilon")] {
let sid = frame(&mut s, page, 200.0, text);
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap();
}
s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let both = format!("{c}|{c}");
let (p1, p3) = (frame(&mut s, 0, 400.0, &both), frame(&mut s, 2, 400.0, &both));
(s, p1, p3)
}
/// Convert Variable to Text turns the selected instance into the value it shows on its own
/// page (Epsilon on page 3, not page 1's Alpha); the other instance and the variable stay.
#[test]
fn convert_variable_to_text_uses_the_instances_page() {
let (mut s, p1, p3) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let second = c.len_utf8() + 1;
s.execute("text.select", &json!({"story": p3.0, "anchor": second, "focus": second + c.len_utf8()})).unwrap();
s.execute("variables.convertToText", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.story(p3).unwrap().text, format!("{c}|Epsilon"), "only the selected instance");
assert_eq!(d.story(p1).unwrap().text, format!("{c}|{c}"));
assert!(d.variable("Chapter Head").is_some(), "the variable stays defined");
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (second, second + "Epsilon".len()), "the converted text stays selected");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(p3).unwrap().text, format!("{c}|{c}"), "one undo step");
}
/// The menu item is enabled only for a selection that holds an instance: not a caret, not
/// text without one.
#[test]
fn convert_variable_to_text_needs_a_selected_instance() {
let (mut s, p1, _) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let bar = c.len_utf8();
let enabled = |s: &Session| (crate::find_command("variables.convertToText").unwrap().enabled)(s).is_ok();
for (anchor, focus) in [(0, 0), (bar, bar + 1)] {
s.execute("text.select", &json!({"story": p1.0, "anchor": anchor, "focus": focus})).unwrap();
assert!(!enabled(&s), "{anchor}..{focus}");
}
s.execute("text.select", &json!({"story": p1.0, "anchor": 0, "focus": bar})).unwrap();
assert!(enabled(&s));
}
/// A story threaded from a frame on page 1, which holds only "Lead", to a frame on page 3,
/// where the caret is left at the end of "Body". A cell's or a note's own byte 0 read in this
/// story is on page 1, its anchor on page 3.
fn threaded(s: &mut Session) -> designcraft_doc::StoryId {
let sp1 = s.doc().unwrap().doc.page_loc(0).unwrap().0;
let sp3 = s.doc().unwrap().doc.page_loc(2).unwrap().0;
let r = s.execute("frame.create", &json!({"spread": sp1, "rect": [72, 500, 400, 520], "content": "text", "text": "Lead\nBody"})).unwrap();
let from = r["id"].as_u64().unwrap();
s.execute("text.thread", &json!({"from": from, "rect": [72, 500, 400, 700], "spread": sp3})).unwrap();
let d = s.doc().unwrap().doc.clone();
let sid = d.item(designcraft_doc::ItemId(from)).unwrap().text_frame().unwrap().story;
let cs = s.cache.get(&d, sid, None);
let page = |pos| designcraft_compose::vars::page_of(&d, &cs, pos);
assert_eq!((page(0), page("Lead\nB".len())), (Some(0), Some(2)), "Lead on page 1, Body on page 3");
s.execute("text.select", &json!({"story": sid.0, "anchor": "Lead\nBody".len(), "focus": "Lead\nBody".len()})).unwrap();
sid
}
/// An instance selected in a table cell becomes the value on the page of the table's anchor.
#[test]
fn convert_variable_to_text_in_a_table_cell() {
let (mut s, ..) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap();
let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap();
let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 };
s.doc_mut().unwrap().selection =
designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: c.len_utf8(), frame: None, cell: Some(cell) });
s.execute("variables.convertToText", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.text_story(sid, Some(cell)).unwrap().text, "Epsilon");
}
/// An instance selected in a footnote becomes the value on the page of the note's reference.
#[test]
fn convert_variable_to_text_in_a_footnote() {
let (mut s, ..) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap();
let id = s.doc().unwrap().doc.story(sid).unwrap().notes[0].id;
let note = designcraft_doc::CellAddr::footnote(id);
let at = s.doc().unwrap().doc.text_story(sid, Some(note)).unwrap().text.find(c).unwrap();
s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(designcraft_doc::TextSel {
story: sid,
anchor: at,
focus: at + c.len_utf8(),
frame: None,
cell: Some(note),
});
s.execute("variables.convertToText", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let text = &d.text_story(sid, Some(note)).unwrap().text;
assert!(!text.contains(c) && text.ends_with("Epsilon"), "{text:?}");
}
/// Text Variables › Convert to Text turns every instance of the variable into the value on
/// its page, in table cells, footnotes and endnotes on the page of their anchor, and keeps the
/// variable.
#[test]
fn convert_all_instances_to_text() {
let (mut s, p1, p3) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
let at_end = |s: &mut Session| {
let n = s.doc().unwrap().doc.story(sid).unwrap().len();
s.execute("text.select", &json!({"story": sid.0, "anchor": n, "focus": n})).unwrap();
};
s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap();
at_end(&mut s);
s.execute("endnote.insert", &json!({"text": c.to_string()})).unwrap();
at_end(&mut s);
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap();
s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.story(p1).unwrap().text, "Alpha|Alpha");
assert_eq!(d.story(p3).unwrap().text, "Epsilon|Epsilon");
let st = d.story(sid).unwrap();
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "Epsilon", "table cell");
assert!(st.notes[0].text.text.ends_with("Epsilon"), "footnote: {:?}", st.notes[0].text.text);
assert!(st.endnotes[0].text.text.ends_with("Epsilon"), "endnote: {:?}", st.endnotes[0].text.text);
assert!(d.variable("Chapter Head").is_some(), "the variable stays defined");
}
/// A caret in a table cell or a footnote after an instance stays after the value Convert to
/// Text writes there, so typing lands after it.
#[test]
fn convert_all_keeps_the_caret_in_a_cell_or_a_footnote() {
for footnote in [false, true] {
let (mut s, ..) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
let text = format!("{c}|");
let at = if footnote {
s.execute("footnote.insert", &json!({"text": text})).unwrap();
designcraft_doc::CellAddr::footnote(s.doc().unwrap().doc.story(sid).unwrap().notes[0].id)
} else {
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap();
let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap();
designcraft_doc::CellAddr { table, row: 0, col: 0 }
};
let n = s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text.len();
s.doc_mut().unwrap().selection =
designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: n, focus: n, frame: None, cell: Some(at) });
s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap();
s.execute("text.insert", &json!({"text": "x"})).unwrap();
let text = &s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text;
assert!(text.ends_with("Epsilon|x"), "footnote {footnote}: {text:?}");
}
}
/// Deleting a variable flattens its instances in table cells too and moves the instances of
/// later variables down, so cells keep showing the same text.
#[test]
+23 -5
View File
@@ -450,11 +450,12 @@ fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap<S
}
_ => {}
}
if c.underline {
out.push_str("text-decoration: underline; ");
}
if c.strikethrough {
out.push_str("text-decoration: line-through; ");
// One declaration: a later `text-decoration` would replace the earlier one.
match (c.underline, c.strikethrough) {
(true, true) => out.push_str("text-decoration: underline line-through; "),
(true, false) => out.push_str("text-decoration: underline; "),
(false, true) => out.push_str("text-decoration: line-through; "),
(false, false) => {}
}
if let Some(h) = hex(doc, &c.fill, c.fill_tint) {
let _ = write!(out, "color: {h}; ");
@@ -2231,4 +2232,21 @@ mod tests {
let last = "<li id=\"en-1\"><a href=\"#enref-1\">1</a> End note</li>";
assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "<li id=\"en-1\"><a href=\"content.xhtml#enref-1\">1</a> End note</li>");
}
#[test]
fn char_css_joins_underline_and_strikethrough_in_one_declaration() {
let d = Document::new(&NewDocument::default());
let css = |u: bool, s: bool| {
let c = CharProps { underline: u, strikethrough: s, ..CharProps::default() };
let mut out = String::new();
char_css(&d, &c, 12.0, &BTreeMap::new(), &mut out);
out
};
let both = css(true, true);
assert_eq!(both.matches("text-decoration:").count(), 1, "{both}");
assert!(both.contains("text-decoration: underline line-through;"), "{both}");
assert!(css(true, false).contains("text-decoration: underline;"));
assert!(css(false, true).contains("text-decoration: line-through;"));
assert!(!css(false, false).contains("text-decoration"));
}
}
+18 -1
View File
@@ -281,9 +281,12 @@ impl PathData {
for el in bp.elements() {
match *el {
PathEl::MoveTo(p) => {
if let Some(sp) = cur.take()
if let Some(mut sp) = cur.take()
&& !sp.anchors.is_empty()
{
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
subpaths.push(sp);
}
cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false });
@@ -418,6 +421,20 @@ mod tests {
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true))
}
#[test]
fn from_bezpath_detects_smooth_on_every_open_subpath() {
let mut bp = BezPath::new();
for _ in 0..2 {
bp.move_to((0.0, 0.0));
bp.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0));
bp.curve_to((10.0, -10.0), (20.0, -10.0), (20.0, 0.0));
}
let pd = PathData::from_bezpath(&bp);
assert_eq!(pd.subpaths.len(), 2);
assert_eq!(pd.subpaths[1].anchors[1].kind, AnchorKind::Smooth);
assert_eq!(pd.subpaths[0].anchors[1].kind, pd.subpaths[1].anchors[1].kind);
}
#[test]
fn anchors_serialize_compactly_and_read_v1_maps() {
let corner = Anchor::corner(Point::new(1.0, 2.0));
+6 -2
View File
@@ -1528,6 +1528,7 @@ impl<'a> Ex<'a> {
}
n!(keep_with_next, "KeepWithNext");
b!(keep_lines_together, "KeepLinesTogether");
b!(ignore_optical_margin, "IgnoreEdgeAlignment");
b!(keep_all_lines, "KeepAllLinesTogether");
n!(keep_first, "KeepFirstLines");
n!(keep_last, "KeepLastLines");
@@ -2226,8 +2227,11 @@ impl<'a> Ex<'a> {
.attr("StoryTitle", "$ID/");
el.push(
El::new("StoryPreference")
.attr("OpticalMarginAlignment", "false")
.attr("OpticalMarginSize", "12")
.attr("OpticalMarginAlignment", bool_s(s.optical_margin.on))
.attr(
"OpticalMarginSize",
num(designcraft_doc::OpticalMargin::clamp_size(s.optical_margin.size).unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE)),
)
.attr("FrameType", "TextFrameType")
.attr("StoryOrientation", if s.vertical { "Vertical" } else { "Horizontal" })
.attr(
+10
View File
@@ -1820,6 +1820,7 @@ impl<'r> Importer<'r> {
a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim()));
a.keep_with_next = u("KeepWithNext");
a.keep_lines_together = e.boolean("KeepLinesTogether");
a.ignore_optical_margin = e.boolean("IgnoreEdgeAlignment");
a.keep_all_lines = e.boolean("KeepAllLinesTogether");
a.keep_first = u("KeepFirstLines");
a.keep_last = u("KeepLastLines");
@@ -2095,6 +2096,14 @@ impl<'r> Importer<'r> {
let format = self.xref_format_names.get(&fmt).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into());
xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format }));
}
let pref = e.find("StoryPreference");
let optical_margin = designcraft_doc::OpticalMargin {
on: pref.and_then(|p| p.boolean("OpticalMarginAlignment")).unwrap_or(false),
size: pref
.and_then(|p| p.num("OpticalMarginSize"))
.and_then(designcraft_doc::OpticalMargin::clamp_size)
.unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE),
};
let mut st = Story {
id,
text,
@@ -2107,6 +2116,7 @@ impl<'r> Importer<'r> {
designcraft_doc::TextDirection::LeftToRight
},
vertical: e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical"),
optical_margin,
rev: 0,
tables,
notes,
+52
View File
@@ -300,6 +300,7 @@ fn indesign_text_defaults() -> Vec<AttrRow> {
"HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit;
"HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone;
"HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized;
"IgnoreEdgeAlignment": "false" => false, "true" => true, |r| r.0.ignore_optical_margin;
"HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word;
"HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column;
"HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight;
@@ -484,6 +485,57 @@ fn text_color_rules_round_trip() {
assert_eq!(p.para.kashidas, Some(false));
}
fn optical_story(pref: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1">
<StoryPreference {pref} StoryOrientation="Horizontal" StoryDirection="LeftToRightDirection"/>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>“Hung” here.</Content><Br/></CharacterStyleRange>
</ParagraphStyleRange>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body" IgnoreEdgeAlignment="true">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>“Flush” here.</Content></CharacterStyleRange>
</ParagraphStyleRange>
</Story>
</idPkg:Story>"#
)
}
/// Story ▸ Optical Margin Alignment and its size, and Ignore Optical Margin on paragraphs and
/// paragraph styles, survive import and export.
#[test]
fn story_optical_margin_round_trips() {
let story = |d: &Document| d.stories.values().find(|s| s.text.starts_with("“Hung”")).cloned().unwrap();
let d = import_idml_with(&fixture_with_story(&optical_story(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#)), &|_| None).unwrap();
let s = story(&d);
assert_eq!(s.optical_margin, designcraft_doc::OpticalMargin { on: true, size: 18.0 });
assert_eq!((s.paras[0].para.ignore_optical_margin, s.paras[1].para.ignore_optical_margin), (None, Some(true)));
let bytes = export_idml(&d);
let text = zip_text(&bytes, "Stories/");
assert!(text.contains(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#), "{text}");
assert!(text.contains(r#"IgnoreEdgeAlignment="true""#), "{text}");
let back = story(&import_idml(&bytes).unwrap());
assert_eq!(back.optical_margin, s.optical_margin);
assert_eq!(back.paras[1].para.ignore_optical_margin, Some(true));
// Sizes outside InDesign's range are clamped; malformed ones and an absent preference are the default.
for (pref, want) in [
(r#"OpticalMarginAlignment="true" OpticalMarginSize="1e9""#, (true, 1296.0)),
(r#"OpticalMarginAlignment="true" OpticalMarginSize="0""#, (true, 0.1)),
(r#"OpticalMarginAlignment="true" OpticalMarginSize="NaN""#, (true, 12.0)),
(r#"OpticalMarginAlignment="false""#, (false, 12.0)),
] {
let d = import_idml_with(&fixture_with_story(&optical_story(pref)), &|_| None).unwrap();
let om = story(&d).optical_margin;
assert_eq!((om.on, om.size), want, "{pref}");
}
// On a paragraph style.
let d = import_idml_with(&fixture_with_styles(&keep_styles(r#"IgnoreEdgeAlignment="true""#)), &|_| None).unwrap();
assert!(d.styles.resolve_para_style("Body").0.ignore_optical_margin);
let styles = zip_text(&export_idml(&d), "Resources/Styles.xml");
assert!(styles.contains(r#"IgnoreEdgeAlignment="true""#), "{styles}");
}
/// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways.
#[test]
fn basic_paragraph_is_normal_paragraph_style() {
+27 -3
View File
@@ -186,8 +186,8 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs {
if let Some(s) = r.on("strike") {
a.strikethrough = Some(s);
}
// 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)) {
// Half-points; a size the engine cannot mean is dropped (#1593).
if let Some(pt) = r.val("sz").and_then(|v| v.parse::<f64>().ok()).map(|sz| sz / 2.0).and_then(designcraft_doc::attrs::imported_type_size) {
a.size = Some(pt);
}
if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) {
@@ -210,7 +210,8 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs {
fn para_attrs(ppr: Option<&El>) -> ParaAttrs {
let mut a = ParaAttrs::default();
let Some(p) = ppr else { return a };
let tw = |v: Option<&str>| v.and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0);
// Twips, clamped: a file can hold 1e300, which became a 5e298 pt indent (#1593).
let tw = |v: Option<&str>| v.and_then(|v| v.parse::<f64>().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure);
if let Some(sp) = p.child("spacing") {
a.space_before = tw(sp.attr("before"));
a.space_after = tw(sp.attr("after"));
@@ -633,6 +634,29 @@ mod tests {
assert_eq!(part_capped(&mut zip, "word/missing.xml", 4096).unwrap(), None);
}
/// A DOCX can hold any number where a measure belongs: `1e300` twips became a 5e298 pt
/// indent, and `w:sz` of `1e300` a 5e299 pt type size. Measures are clamped to the widest
/// page and a size the engine cannot mean is dropped (#1593).
#[test]
fn a_measure_or_size_a_file_cannot_mean_is_bounded() {
use designcraft_doc::attrs::MAX_MEASURE;
let doc = format!(
r#"<w:document {W}><w:body><w:p><w:pPr>
<w:spacing w:before="1e300" w:after="-1e300"/><w:ind w:left="1e300" w:firstLine="-1e300"/>
</w:pPr><w:r><w:rPr><w:sz w:val="1e300"/></w:rPr><w:t>big</w:t></w:r></w:p></w:body></w:document>"#
);
let i = import(&docx(&doc, "", "")).unwrap();
let p = &i.story.paras[0].para;
for (what, v) in
[("space before", p.space_before), ("space after", p.space_after), ("left indent", p.left_indent), ("first line", p.first_line_indent)]
{
let v = v.expect("the value is kept, bounded");
assert!(v.is_finite() && v.abs() <= MAX_MEASURE, "{what} is within the widest page: {v}");
}
let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size;
assert_eq!(size, None, "a size the engine cannot mean is dropped, so the style's size stands");
}
#[test]
fn imports_styles_runs_footnotes_and_tables() {
let styles = format!(
+2 -1
View File
@@ -129,7 +129,8 @@ fn number_style(fmt: &str) -> Format {
}
fn parse_level(e: &El) -> Level {
let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0);
// Twips, clamped as elsewhere: a level's indent is a measure, not any number (#1593).
let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::<f64>().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure);
let ind = e.child("pPr").and_then(|p| p.child("ind"));
Level {
format: e.val("numFmt").map_or(Format::Number(NumberStyle::Arabic), |f| number_style(&f)),
+1 -1
View File
@@ -229,7 +229,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
"strike" => restyle(&|s| s.strike = on),
// Half-points; sizes outside the engine's 0.1–1296 pt range fall back to the default.
"fs" => restyle(&|s| {
s.size = param.map(|p| p as f64 / 2.0).filter(|pt| (0.1..=1296.0).contains(pt));
s.size = param.map(|p| p as f64 / 2.0).and_then(designcraft_doc::attrs::imported_type_size);
}),
"plain" => restyle(&|s| {
s.bold = false;
+21 -1
View File
@@ -201,7 +201,9 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
}
"cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()),
"cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()),
"cSize" => fmt.over.size = value.trim().parse().ok().filter(|s: &f64| s.is_finite()),
// A size the engine cannot mean (0, negative, 1e300) is dropped, so the text
// keeps its style's size (#1593).
"cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size),
"DefineParaStyle" | "DefineCharStyle" => {
let nm = value.split('=').next().unwrap_or("").trim().to_string();
let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles };
@@ -276,6 +278,24 @@ mod tests {
assert_eq!(import("<ASCII-WIN>\r\n<ParaStyle:Body>A<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC");
}
/// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a
/// negative, and tagged text took all three and set them on the text (#1593). They are now
/// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size.
#[test]
fn a_char_size_the_engine_cannot_mean_is_dropped() {
for v in ["1e300", "-5", "0", "1297", "0.09"] {
let src = format!("<ASCII-WIN>\r\n<ParaStyle:Body><cSize:{v}>big<cSize:>");
let i = import(src.as_bytes()).unwrap();
let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size;
assert_eq!(size, None, "cSize {v} is not a size the engine can mean");
}
// A size inside the range still arrives.
let src = "<ASCII-WIN>\r\n<ParaStyle:Body><cSize:18>big<cSize:>";
let i = import(src.as_bytes()).unwrap();
let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size;
assert_eq!(size, Some(18.0));
}
/// `f64::from_str` accepts "NaN" and "inf"; a non-finite size must not reach the story.
#[test]
fn non_finite_char_size_is_ignored() {
+87 -3
View File
@@ -238,8 +238,14 @@ pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> {
Some((pi, qi))
}
/// Scroll so absolute page `abs` is centred (keeps zoom).
/// Scroll so absolute page `abs` is centred (keeps zoom). A jump Go Back can return from.
pub fn go_to_page(app: &mut DesignApp, abs: usize) {
note_jump(app, false);
centre_page(app, abs);
note_jump(app, true);
}
fn centre_page(app: &mut DesignApp, abs: usize) {
let Some(st) = app.session.active() else { return };
let layout = CanvasLayout::new(&st.doc, false);
let Some(pr) = layout.page_rect(&st.doc, abs) else { return };
@@ -249,6 +255,65 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) {
}
}
/// The document's pages by id, in page order.
fn page_ids(doc: &Document) -> Vec<designcraft_doc::ids::PageId> {
doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect()
}
/// The page in view and the view centre's offset from that page's centre. Parent pages have none,
/// nor has a view still waiting to be fitted (leaving parent pages for a document page).
pub(crate) fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> {
let st = app.session.active().filter(|st| !st.editing_parents)?;
let (v, r) = (app.view().filter(|v| v.fitted)?, app.canvas_rect?);
let abs = current_page(app)?;
let id = st.doc.page(abs)?.id;
let pr = CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)?;
let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom);
Some((id, c - pr.center()))
}
/// Layout ▸ Go Back / Go Forward record jumps (Go to Page, the page and spread commands, the Pages
/// panel), not scrolling: where the view is when a jump starts, and where the jump took it.
fn note_jump(app: &mut DesignApp, to: bool) {
let Some((id, offset)) = page_in_view(app) else { return };
if let Some(h) = app.page_history_mut() {
if to { h.visit(id, offset) } else { h.leave(id, offset) }
}
}
/// Whether Layout ▸ Go Back (`step` < 0) or Go Forward has a page to go to from the view as it is.
pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool {
let Some(st) = app.session.active().filter(|st| !st.editing_parents) else { return false };
// A view still waiting to be fitted is refitted next frame, which would replace the destination.
if !app.view().is_some_and(|v| v.fitted) {
return false;
}
let ids = page_ids(&st.doc);
let Some(mut h) = app.page_history().cloned() else { return false };
if let Some((id, offset)) = page_in_view(app) {
h.leave(id, offset);
}
h.can_go(step, |id| ids.contains(&id))
}
/// Layout ▸ Go Back (`step` < 0) or Go Forward: show the page viewed before or after this one, where
/// the view was on it (zoom kept).
pub fn go_through_history(app: &mut DesignApp, step: isize) {
if !app.view().is_some_and(|v| v.fitted) {
return;
}
note_jump(app, false);
let Some(ids) = app.session.active().filter(|st| !st.editing_parents).map(|st| page_ids(&st.doc)) else { return };
let Some((id, offset)) = app.page_history_mut().and_then(|h| h.go(step, |id| ids.contains(&id))) else { return };
let Some(st) = app.session.active() else { return };
let Some(pr) = ids.iter().position(|p| *p == id).and_then(|abs| CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)) else { return };
let Some(rect) = app.canvas_rect else { return };
let c = pr.center() + offset;
if let Some(v) = app.view_mut() {
v.origin = Point::new(c.x - rect.width() as f64 / 2.0 / v.zoom, c.y - rect.height() as f64 / 2.0 / v.zoom);
}
}
/// Presentation: the spread in view, centred and as large as `rect` allows.
fn present_fit(app: &mut DesignApp, rect: Rect) {
let Some(st) = app.session.active() else { return };
@@ -268,10 +333,29 @@ fn present_fit(app: &mut DesignApp, rect: Rect) {
/// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the
/// first and last spread. Parent spreads while they are being edited.
pub fn step_spread(app: &mut DesignApp, delta: isize) {
// At the first or last spread only the view moves, within the spread: no jump for Go Back.
let jump = spread_step(app, delta).is_some_and(|(cur, to)| cur != to);
if jump {
note_jump(app, false);
}
centre_spread(app, delta);
if jump {
note_jump(app, true);
}
}
/// The slot in view and the one `delta` spreads after (or before) it, stopping at the ends.
fn spread_step(app: &DesignApp, delta: isize) -> Option<(usize, usize)> {
let st = app.session.active()?;
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let cur = current_slot(app, &layout)?;
Some((cur, cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1))))
}
fn centre_spread(app: &mut DesignApp, delta: isize) {
let Some((_, to)) = spread_step(app, delta) else { return };
let Some(st) = app.session.active() else { return };
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let Some(cur) = current_slot(app, &layout) else { return };
let to = cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1));
let Some(b) = layout.slots.get(to).map(|s| s.bounds) else { return };
let Some(rect) = app.canvas_rect else { return };
if let Some(v) = app.view_mut() {
+1 -1
View File
@@ -579,7 +579,7 @@ const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 1
const PARA_MODE: &str = "ctl_text_para_mode";
/// An icon and a field after it, as the Control panel lays its fields out.
fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option<f64> {
pub(crate) fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
icons::paint(ui.painter(), r, icon, t.icon);
+34
View File
@@ -4059,6 +4059,11 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
});
ui.end_row();
});
ui.add_space(6.0);
let mut ignore = cur(d, "p.ignoreOpticalMargin", &pv["ignoreOpticalMargin"]).as_bool().unwrap_or(false);
if ui.checkbox(&mut ignore, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ignore Optical Margin"))).changed() {
d.fields.insert("p.ignoreOpticalMargin".into(), json!(ignore));
}
}
"tabs" => style_tabs(&app.ui.language, ui, d, &pv, &doc),
"rules" => paragraph_rules(app, ui, d, "p.", &pv),
@@ -7867,6 +7872,35 @@ mod tests {
assert_eq!(props.number_label(3), "III)\t");
}
#[test]
fn paragraph_style_options_ignore_optical_margin_is_set_on_ok() {
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": "Flush"})).unwrap();
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Flush", "section": "indents"})));
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("ignore_optical_margin_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("Ignore Optical Margin").click();
h.run_steps(3);
h.get_by_label(" OK ").click();
h.run_steps(3);
let app = h.state();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().doc.styles.para("Flush").unwrap().para.ignore_optical_margin, Some(true));
}
#[test]
fn paragraph_style_options_keep_a_bad_grep_pattern_open_with_the_error() {
let (mut app, _) = ruled_paragraph();
+2
View File
@@ -23,6 +23,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("typography", "Typography", "panel-typography"),
("glyphs", "Glyphs", "panel-character"),
("paragraph", "Paragraph", "panel-paragraph"),
("story", "Story", "panel-text-frame"),
("textWrap", "Text Wrap", "panel-wrap"),
("table", "Table", "panel-table"),
("align", "Align", "panel-align"),
@@ -285,6 +286,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
"typography" => panels::typography::show(app, ui),
"glyphs" => panels::glyphs::show(app, ui),
"paragraph" => panels::properties::paragraph_panel(app, ui),
"story" => panels::story::show(app, ui),
"textWrap" => panels::properties::wrap_panel(app, ui),
"align" => panels::properties::align_panel(app, ui),
"pathfinder" => panels::properties::pathfinder_panel(app, ui),
+49 -7
View File
@@ -116,10 +116,17 @@ fn is_on(fx: &Value, page: &str) -> bool {
/// The `object.effects` params the dialog stands for.
pub fn params(d: &Dialog) -> Value {
json!({"ids": d.fields.get("ids").cloned().unwrap_or(json!([])), "effects": d.fields.get("fx").cloned().unwrap_or(json!({})),
"opacity": d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), "blend": d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal")})
let mut p = json!({"ids": d.fields.get("ids").cloned().unwrap_or(json!([])), "effects": d.fields.get("fx").cloned().unwrap_or(json!({})),
"opacity": d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), "blend": d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal")});
if let Some(a) = d.fields.get(GLOBAL_LIGHT).and_then(Value::as_f64) {
p["globalLight"] = json!(a);
}
p
}
/// The global light angle as changed in the dialog (an Angle field with Use Global Light on).
const GLOBAL_LIGHT: &str = "globalLight";
/// Apply: the preview goes and the settings are made once, as one undo step.
pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result<Value, String> {
if d.fields.contains_key(PREVIEWED) {
@@ -202,12 +209,19 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) {
}
}
fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global: f64) {
let mut v = if enabled { num(d, path) } else { global };
let r = ui.add_enabled(enabled, egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1));
/// The effect's own angle at `path`, or with Use Global Light (`own` false) the document's
/// global light angle: editing it then changes that one, for every effect that uses it.
fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], own: bool, global: f64) -> egui::Response {
let mut v = if own { num(d, path) } else { global };
let r = ui.add(egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1));
if r.changed() {
set(d, path, json!(v));
if own {
set(d, path, json!(v));
} else {
d.fields.insert(GLOBAL_LIGHT.into(), json!(v));
}
}
r
}
fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) {
@@ -273,7 +287,9 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
let lang = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
let unit = app.session.active().map_or(Unit::Points, |st| st.doc.settings.horizontal_units);
let global = app.session.active().map_or(120.0, |st| st.doc.settings.global_light);
// The global light angle as the dialog will set it (an edit in it shows at once).
let global =
d.fields.get(GLOBAL_LIGHT).and_then(Value::as_f64).unwrap_or_else(|| app.session.active().map_or(120.0, |st| st.doc.settings.global_light));
let row = |ui: &mut egui::Ui, l: &str| crate::rtl::label(ui, tr(l));
crate::rtl::label(ui, egui::RichText::new(tr(PAGES.iter().find(|p| p.0 == page).map_or("Transparency", |p| p.1))).font(semibold(14.0)));
let grid = |id: &str| egui::Grid::new(("fx_grid", id.to_string())).num_columns(4).spacing([8.0, 6.0]);
@@ -657,6 +673,32 @@ mod tests {
texts
}
/// With Use Global Light on, the Angle is the document's global light and stays editable:
/// a change there is applied to the document with the effects (it was greyed out).
#[test]
fn the_angle_with_global_light_sets_the_global_light() {
let (mut app, _) = app_with_frame();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, "en");
crate::menus::activate(&mut app, "object.dropShadow", &Value::Null);
let d = app.ui.dialog.as_mut().unwrap();
set(d, &["dropShadow", "globalLight"], json!(true));
frame(&mut app, &ctx);
// The field shows the global angle and is enabled: a DragValue that isn't disabled.
let global = app.session.doc().unwrap().doc.settings.global_light;
let mut d = app.ui.dialog.clone().unwrap();
let ui_ctx = egui::Context::default();
let mut enabled = false;
let mut out = ui_ctx.run_ui(egui::RawInput::default(), |ui| {
enabled = angle(ui, &mut d, &["dropShadow", "angle"], false, global).enabled();
});
out.textures_delta.clear();
assert!(enabled, "the Angle stays editable with Use Global Light");
d.fields.insert(GLOBAL_LIGHT.into(), json!(15.0));
confirm(&mut app, &d).unwrap();
assert_eq!(app.session.doc().unwrap().doc.settings.global_light, 15.0);
}
#[test]
fn set_makes_the_path_and_ignores_an_empty_one() {
let mut d = Dialog::new(ID, json!({"fx": {}}));
+15
View File
@@ -200,6 +200,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Previous Page", ["Vorherige Seite", "Page précédente", "Página anterior", "前のページ", "上一页"]),
("Next Page", ["Nächste Seite", "Page suivante", "Página siguiente", "次のページ", "下一页"]),
("Last Page", ["Letzte Seite", "Dernière page", "Última página", "最後のページ", "最后一页"]),
("Go Back", ["Zurück gehen", "Revenir", "Retroceder", "前に戻る", "后退"]),
("Go Forward", ["Vorwärts gehen", "Avancer", "Avanzar", "次に進む", "前进"]),
(
"Close Tab Group",
["Registerkartengruppe schließen", "Fermer le groupe d'onglets", "Cerrar grupo de fichas", "タブグループを閉じる", "关闭选项卡组"],
@@ -1774,6 +1776,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Convert to Form Field",
["Convert to Form Field", "Convertir en champ de formulaire", "Convert to Form Field", "Convert to Form Field", "转换为表单域"],
),
("Convert to Note", ["Convert to Note", "Convertir en note", "Convert to Note", "Convert to Note", "转换为注释"]),
("Conveyor", ["Conveyor", "Convoyeur", "Conveyor", "Conveyor", "传送带"]),
("Copy Link(s) To…", ["Copy Link(s) To…", "Copier les liens vers…", "Copy Link(s) To…", "Copy Link(s) To…", "复制链接至…"]),
("Corner Options…", ["Corner Options…", "Options d'arrondi…", "Corner Options…", "Corner Options…", "角选项…"]),
@@ -2025,6 +2028,16 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]),
("Insert Variable", ["Insert Variable", "Insérer une variable", "Insert Variable", "Insert Variable", "插入变量"]),
(
"Convert Variable to Text",
[
"Variable in Text konvertieren",
"Convertir la variable en texte",
"Convertir variable en texto",
"変数をテキストに変換",
"将变量转换为文本",
],
),
("Inspect Document", ["Inspect Document", "Inspecter le document", "Inspect Document", "Inspect Document", "检查文档"]),
(
"Interface Color Theme",
@@ -2084,6 +2097,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("New Table Style…", ["New Table Style…", "Nouveau style de tableau…", "New Table Style…", "New Table Style…", "新建表样式…"]),
("Next Cell", ["Next Cell", "Cellule suivante", "Next Cell", "Next Cell", "下一个单元格"]),
("Next Document", ["Next Document", "Document suivant", "Next Document", "Next Document", "下一个文档"]),
("Next Note", ["Next Note", "Note suivante", "Next Note", "Next Note", "下一个注释"]),
("Next Object Above", ["Next Object Above", "Objet suivant au-dessus", "Next Object Above", "Next Object Above", "上方下一个对象"]),
("Next Object Below", ["Next Object Below", "Objet suivant en dessous", "Next Object Below", "Next Object Below", "下方下一个对象"]),
(
@@ -2162,6 +2176,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Preflight Document", ["Preflight Document", "Contrôle en amont du document", "Preflight Document", "Preflight Document", "预检文档"]),
("Previous Cell", ["Previous Cell", "Cellule précédente", "Previous Cell", "Previous Cell", "上一个单元格"]),
("Previous Document", ["Previous Document", "Document précédent", "Previous Document", "Previous Document", "上一个文档"]),
("Previous Note", ["Previous Note", "Note précédente", "Previous Note", "Previous Note", "上一个注释"]),
(
"Previous Object in Group",
["Previous Object in Group", "Objet précédent du groupe", "Previous Object in Group", "Previous Object in Group", "组中的上一个对象"],
+6
View File
@@ -207,6 +207,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "الصفحة السابقة"),
("First Page", "الصفحة الأولى"),
("Last Page", "الصفحة الأخيرة"),
("Go Back", "رجوع"),
("Go Forward", "تقدم"),
("Font", "خط"),
("Size", "الحجم"),
("Character", "حرف"),
@@ -1378,6 +1380,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "تحويل النص إلى جدول"),
("Convert to Button", "تحويل إلى زر"),
("Convert to Form Field", "تحويل إلى حقل نموذج"),
("Convert to Note", "تحويل إلى ملاحظة"),
("Copy Link(s) To…", "نسخ الروابط إلى…"),
("Corner Options…", "خيارات الزوايا…"),
("Create Alternate Layout…", "إنشاء تخطيط بديل…"),
@@ -1448,6 +1451,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "إدراج تعبير رياضي…"),
("Insert Pages…", "إدراج صفحات…"),
("Insert Variable", "إدراج متغير"),
("Convert Variable to Text", "تحويل المتغير إلى نص"),
("Inspect Document", "فحص المستند"),
("Interface Color Theme", "نسق ألوان الواجهة"),
("Join the ArtCraft Discord…", "الانضمام إلى Discord الخاص بـ ArtCraft…"),
@@ -1481,6 +1485,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "نمط جدول جديد…"),
("Next Cell", "الخلية التالية"),
("Next Document", "المستند التالي"),
("Next Note", "الملاحظة التالية"),
("Next Object Above", "الكائن التالي أعلى"),
("Next Object Below", "الكائن التالي أسفل"),
("Next Object in Group", "الكائن التالي في المجموعة"),
@@ -1509,6 +1514,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "فحص المستند قبل الطباعة"),
("Previous Cell", "الخلية السابقة"),
("Previous Document", "المستند السابق"),
("Previous Note", "الملاحظة السابقة"),
("Previous Object in Group", "الكائن السابق في المجموعة"),
("Printers", "طابعات"),
("Proof Colors", "ألوان المعاينة الطباعية"),
+6
View File
@@ -507,6 +507,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Převést text na tabulku"),
("Convert to Button", "Převést na tlačítko"),
("Convert to Form Field", "Převést na pole formuláře"),
("Convert to Note", "Převést na poznámku"),
("Conveyor", "Dopravník"),
("Copy Link(s) To…", "Kopírovat propojení do…"),
("Corner Options…", "Možnosti rohů…"),
@@ -597,6 +598,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Vložit matematický výraz…"),
("Insert Pages…", "Vložit stránky…"),
("Insert Variable", "Vložit proměnnou"),
("Convert Variable to Text", "Převést proměnnou na text"),
("Inspect Document", "Prozkoumat dokument"),
("Interface Color Theme", "Barevný motiv rozhraní"),
("Join the ArtCraft Discord…", "Připojit se k Discordu ArtCraft…"),
@@ -632,6 +634,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Nový styl tabulky…"),
("Next Cell", "Další buňka"),
("Next Document", "Další dokument"),
("Next Note", "Další poznámka"),
("Next Object Above", "Další objekt nad"),
("Next Object Below", "Další objekt pod"),
("Next Object in Group", "Další objekt ve skupině"),
@@ -665,6 +668,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Předtisková kontrola dokumentu"),
("Previous Cell", "Předchozí buňka"),
("Previous Document", "Předchozí dokument"),
("Previous Note", "Předchozí poznámka"),
("Previous Object in Group", "Předchozí objekt ve skupině"),
("Primary Text Frame", "Primární textový rámeček"),
("Print Booklet…", "Tisk brožury…"),
@@ -1480,6 +1484,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Předchozí stránka"),
("First Page", "První stránka"),
("Last Page", "Poslední stránka"),
("Go Back", "Přejít zpět"),
("Go Forward", "Přejít vpřed"),
("Object Styles", "Styly objektů"),
("Row", "Řádek"),
("Column", "Sloupec"),
+4
View File
@@ -544,6 +544,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Text in Tabelle umwandeln"),
("Convert to Button", "In Schaltfläche umwandeln"),
("Convert to Form Field", "In Formularfeld umwandeln"),
("Convert to Note", "In Notiz umwandeln"),
("Conveyor", "Förderband"),
("Copy Link(s) To…", "Verknüpfung(en) kopieren nach …"),
("Corner Options…", "Eckenoptionen …"),
@@ -634,6 +635,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Mathematischen Ausdruck einfügen …"),
("Insert Pages…", "Seiten einfügen …"),
("Insert Variable", "Variable einfügen"),
("Convert Variable to Text", "Variable in Text konvertieren"),
("Inspect Document", "Dokument untersuchen"),
("Interface Color Theme", "Farbschema der Oberfläche"),
("Join the ArtCraft Discord…", "Dem ArtCraft-Discord beitreten …"),
@@ -669,6 +671,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Neues Tabellenformat …"),
("Next Cell", "Nächste Zelle"),
("Next Document", "Nächstes Dokument"),
("Next Note", "Nächste Notiz"),
("Next Object Above", "Nächstes Objekt darüber"),
("Next Object Below", "Nächstes Objekt darunter"),
("Next Object in Group", "Nächstes Objekt in der Gruppe"),
@@ -702,6 +705,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Preflight für Dokument"),
("Previous Cell", "Vorherige Zelle"),
("Previous Document", "Vorheriges Dokument"),
("Previous Note", "Vorherige Notiz"),
("Previous Object in Group", "Vorheriges Objekt in der Gruppe"),
("Primary Text Frame", "Primärer Textrahmen"),
("Print Booklet…", "Broschüre drucken …"),
+6
View File
@@ -635,6 +635,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Converti testo in tabella"),
("Convert to Button", "Converti in pulsante"),
("Convert to Form Field", "Converti in campo modulo"),
("Convert to Note", "Converti in nota"),
("Conveyor", "Nastro"),
("Copy Link(s) To…", "Copia collegamenti in…"),
("Corner Options…", "Opzioni angoli…"),
@@ -725,6 +726,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Inserisci espressione matematica…"),
("Insert Pages…", "Inserisci pagine…"),
("Insert Variable", "Inserisci variabile"),
("Convert Variable to Text", "Converti variabile in testo"),
("Inspect Document", "Ispeziona documento"),
("Interface Color Theme", "Tema colore dell'interfaccia"),
("Join the ArtCraft Discord…", "Unisciti al Discord di ArtCraft…"),
@@ -760,6 +762,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Nuovo stile tabella…"),
("Next Cell", "Cella successiva"),
("Next Document", "Documento successivo"),
("Next Note", "Nota successiva"),
("Next Object Above", "Oggetto successivo sopra"),
("Next Object Below", "Oggetto successivo sotto"),
("Next Object in Group", "Oggetto successivo nel gruppo"),
@@ -793,6 +796,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Verifica preliminare documento"),
("Previous Cell", "Cella precedente"),
("Previous Document", "Documento precedente"),
("Previous Note", "Nota precedente"),
("Previous Object in Group", "Oggetto precedente nel gruppo"),
("Primary Text Frame", "Cornice di testo principale"),
("Print Booklet…", "Stampa opuscolo…"),
@@ -1654,6 +1658,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Pagina precedente"),
("First Page", "Prima pagina"),
("Last Page", "Ultima pagina"),
("Go Back", "Torna indietro"),
("Go Forward", "Vai avanti"),
("Object Styles", "Stili oggetto"),
("Row", "Riga"),
("Column", "Colonna"),
+4
View File
@@ -736,6 +736,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "テキストを表に変換"),
("Convert to Button", "ボタンに変換"),
("Convert to Form Field", "フォームフィールドに変換"),
("Convert to Note", "注釈に変換"),
("Conveyor", "コンベヤー"),
("Copy Link(s) To…", "リンクを指定先にコピー…"),
("Corner Options…", "角のオプション…"),
@@ -826,6 +827,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "数式を挿入…"),
("Insert Pages…", "ページを挿入…"),
("Insert Variable", "変数を挿入"),
("Convert Variable to Text", "変数をテキストに変換"),
("Inspect Document", "ドキュメントを調査"),
("Interface Color Theme", "インターフェイスのカラーテーマ"),
("Join the ArtCraft Discord…", "ArtCraft の Discord に参加…"),
@@ -861,6 +863,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "新規表スタイル…"),
("Next Cell", "次のセル"),
("Next Document", "次のドキュメント"),
("Next Note", "次の注釈"),
("Next Object Above", "1つ前面のオブジェクト"),
("Next Object Below", "1つ背面のオブジェクト"),
("Next Object in Group", "グループ内の次のオブジェクト"),
@@ -894,6 +897,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "ドキュメントのプリフライト"),
("Previous Cell", "前のセル"),
("Previous Document", "前のドキュメント"),
("Previous Note", "前の注釈"),
("Previous Object in Group", "グループ内の前のオブジェクト"),
("Primary Text Frame", "主テキストフレーム"),
("Print Booklet…", "小冊子をプリント…"),
+6
View File
@@ -562,6 +562,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "ტექსტის ცხრილად გარდაქმნა"),
("Convert to Button", "ღილაკად გარდაქმნა"),
("Convert to Form Field", "ფორმის ველად გარდაქმნა"),
("Convert to Note", "შენიშვნად გარდაქმნა"),
("Conveyor", "კონვეიერი"),
("Copy Link(s) To…", "ბმული ფაილების კოპირება…"),
("Corner Options…", "კუთხეების პარამეტრები…"),
@@ -653,6 +654,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "მათემატიკური გამოსახულების ჩასმა…"),
("Insert Pages…", "გვერდების ჩასმა…"),
("Insert Variable", "ცვლადის ჩასმა"),
("Convert Variable to Text", "ცვლადის ტექსტად გარდაქმნა"),
("Inspect Document", "დოკუმენტის შემოწმება"),
("Interface Color Theme", "ინტერფეისის ფერთა თემა"),
("Join the ArtCraft Discord…", "შემოუერთდით ArtCraft-ის Discord-ს…"),
@@ -688,6 +690,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "ცხრილის ახალი სტილი…"),
("Next Cell", "შემდეგი უჯრა"),
("Next Document", "შემდეგი დოკუმენტი"),
("Next Note", "შემდეგი შენიშვნა"),
("Next Object Above", "შემდეგი ობიექტი ზემოთ"),
("Next Object Below", "შემდეგი ობიექტი ქვემოთ"),
("Next Object in Group", "ჯგუფის შემდეგი ობიექტი"),
@@ -721,6 +724,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "დოკუმენტის წინასწარი შემოწმება"),
("Previous Cell", "წინა უჯრა"),
("Previous Document", "წინა დოკუმენტი"),
("Previous Note", "წინა შენიშვნა"),
("Previous Object in Group", "ჯგუფის წინა ობიექტი"),
("Primary Text Frame", "ძირითადი ტექსტური ჩარჩო"),
("Print Booklet…", "ბროშურის დაბეჭდვა…"),
@@ -1586,6 +1590,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "წინა გვერდი"),
("First Page", "პირველი გვერდი"),
("Last Page", "ბოლო გვერდი"),
("Go Back", "უკან დაბრუნება"),
("Go Forward", "წინ გადასვლა"),
("Object Styles", "ობიექტის სტილები"),
("Row", "სტრიქონი"),
("Rulers", "სახაზავები"),
+6
View File
@@ -514,6 +514,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Zamień tekst na tabelę"),
("Convert to Button", "Zamień na przycisk"),
("Convert to Form Field", "Zamień na pole formularza"),
("Convert to Note", "Zamień na notatkę"),
("Conveyor", "Przenośnik"),
("Copy Link(s) To…", "Kopiuj łącza do…"),
("Corner Options…", "Opcje narożników…"),
@@ -604,6 +605,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Wstaw wyrażenie matematyczne…"),
("Insert Pages…", "Wstaw strony…"),
("Insert Variable", "Wstaw zmienną"),
("Convert Variable to Text", "Zamień zmienną na tekst"),
("Inspect Document", "Sprawdź dokument"),
("Interface Color Theme", "Motyw kolorystyczny interfejsu"),
("Join the ArtCraft Discord…", "Dołącz do Discorda ArtCraft…"),
@@ -639,6 +641,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Nowy styl tabeli…"),
("Next Cell", "Następna komórka"),
("Next Document", "Następny dokument"),
("Next Note", "Następna notatka"),
("Next Object Above", "Następny obiekt powyżej"),
("Next Object Below", "Następny obiekt poniżej"),
("Next Object in Group", "Następny obiekt w grupie"),
@@ -672,6 +675,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Inspekcja wstępna dokumentu"),
("Previous Cell", "Poprzednia komórka"),
("Previous Document", "Poprzedni dokument"),
("Previous Note", "Poprzednia notatka"),
("Previous Object in Group", "Poprzedni obiekt w grupie"),
("Primary Text Frame", "Główna ramka tekstowa"),
("Print Booklet…", "Drukuj broszurę…"),
@@ -1517,6 +1521,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Poprzednia strona"),
("First Page", "Pierwsza strona"),
("Last Page", "Ostatnia strona"),
("Go Back", "Przejdź wstecz"),
("Go Forward", "Przejdź dalej"),
("Object Styles", "Style obiektów"),
("Row", "Wiersz"),
("Rulers", "Linijki"),
+6
View File
@@ -213,6 +213,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Página anterior"),
("First Page", "Primeira página"),
("Last Page", "Última página"),
("Go Back", "Voltar"),
("Go Forward", "Avançar"),
("Font", "Fonte"),
("Size", "Tamanho"),
("Character", "Caractere"),
@@ -1515,6 +1517,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Converter texto em tabela"),
("Convert to Button", "Converter em botão"),
("Convert to Form Field", "Converter em campo de formulário"),
("Convert to Note", "Converter em nota"),
("Copy Link(s) To…", "Copiar link(s) para…"),
("Corner Options…", "Opções de canto…"),
("Create Frame", "Criar quadro"),
@@ -1581,6 +1584,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Cross-Reference…", "Inserir referência cruzada…"),
("Insert Math Expression…", "Inserir expressão matemática…"),
("Insert Variable", "Inserir variável"),
("Convert Variable to Text", "Converter variável em texto"),
("Inspect Document", "Inspecionar documento"),
("Interface Color Theme", "Tema de cores da interface"),
("Join the ArtCraft Discord…", "Entrar no Discord do ArtCraft…"),
@@ -1614,6 +1618,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Novo estilo de tabela…"),
("Next Cell", "Próxima célula"),
("Next Document", "Próximo documento"),
("Next Note", "Próxima nota"),
("Next Object Above", "Próximo objeto acima"),
("Next Object Below", "Próximo objeto abaixo"),
("Next Object in Group", "Próximo objeto no grupo"),
@@ -1641,6 +1646,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Verificar documento (preflight)"),
("Previous Cell", "Célula anterior"),
("Previous Document", "Documento anterior"),
("Previous Note", "Nota anterior"),
("Previous Object in Group", "Objeto anterior no grupo"),
("Printers", "Impressoras"),
("Proof Colors", "Cores de prova"),
+6
View File
@@ -494,6 +494,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Преобразовать текст в таблицу"),
("Convert to Button", "Преобразовать в кнопку"),
("Convert to Form Field", "Преобразовать в поле формы"),
("Convert to Note", "Преобразовать в заметку"),
("Conveyor", "Конвейер"),
("Copy Link(s) To…", "Копировать связанные файлы в…"),
("Corner Options…", "Параметры углов…"),
@@ -584,6 +585,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Вставить математическое выражение…"),
("Insert Pages…", "Вставить страницы…"),
("Insert Variable", "Вставить переменную"),
("Convert Variable to Text", "Преобразовать переменную в текст"),
("Inspect Document", "Проверить документ"),
("Interface Color Theme", "Цветовая тема интерфейса"),
("Join the ArtCraft Discord…", "Присоединиться к Discord ArtCraft…"),
@@ -619,6 +621,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Новый стиль таблицы…"),
("Next Cell", "Следующая ячейка"),
("Next Document", "Следующий документ"),
("Next Note", "Следующая заметка"),
("Next Object Above", "Следующий объект выше"),
("Next Object Below", "Следующий объект ниже"),
("Next Object in Group", "Следующий объект в группе"),
@@ -652,6 +655,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Предпечатная проверка документа"),
("Previous Cell", "Предыдущая ячейка"),
("Previous Document", "Предыдущий документ"),
("Previous Note", "Предыдущая заметка"),
("Previous Object in Group", "Предыдущий объект в группе"),
("Primary Text Frame", "Основная текстовая рамка"),
("Print Booklet…", "Печать буклета…"),
@@ -1468,6 +1472,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Предыдущая страница"),
("First Page", "Первая страница"),
("Last Page", "Последняя страница"),
("Go Back", "Назад"),
("Go Forward", "Вперёд"),
("Object Styles", "Стили объектов"),
("Row", "Строка"),
("Column", "Столбец"),
+6
View File
@@ -638,6 +638,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Перетворити текст на таблицю"),
("Convert to Button", "Перетворити на кнопку"),
("Convert to Form Field", "Перетворити на поле форми"),
("Convert to Note", "Перетворити на примітку"),
("Conveyor", "Конвеєр"),
("Copy Link(s) To…", "Копіювати пов’язані файли до…"),
("Corner Options…", "Параметри кутів…"),
@@ -728,6 +729,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Вставити математичний вираз…"),
("Insert Pages…", "Вставити сторінки…"),
("Insert Variable", "Вставити змінну"),
("Convert Variable to Text", "Перетворити змінну на текст"),
("Inspect Document", "Перевірити документ"),
("Interface Color Theme", "Колірна тема інтерфейсу"),
("Join the ArtCraft Discord…", "Приєднатися до Discord ArtCraft…"),
@@ -763,6 +765,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Новий стиль таблиці…"),
("Next Cell", "Наступна клітинка"),
("Next Document", "Наступний документ"),
("Next Note", "Наступна примітка"),
("Next Object Above", "Наступний об’єкт вище"),
("Next Object Below", "Наступний об’єкт нижче"),
("Next Object in Group", "Наступний об’єкт у групі"),
@@ -796,6 +799,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Перевірити документ перед друком"),
("Previous Cell", "Попередня клітинка"),
("Previous Document", "Попередній документ"),
("Previous Note", "Попередня примітка"),
("Previous Object in Group", "Попередній об’єкт у групі"),
("Primary Text Frame", "Основний текстовий фрейм"),
("Print Booklet…", "Друк буклета…"),
@@ -1651,6 +1655,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Попередня сторінка"),
("First Page", "Перша сторінка"),
("Last Page", "Остання сторінка"),
("Go Back", "Назад"),
("Go Forward", "Вперед"),
("Object Styles", "Стилі об’єктів"),
("Row", "Рядок"),
("Column", "Стовпець"),
+19
View File
@@ -21,6 +21,7 @@ pub mod menus;
mod modal_drag;
mod new_document;
mod object_control;
mod page_history;
mod panel_docking;
pub mod panels;
mod prefs;
@@ -194,6 +195,12 @@ pub struct UiState {
/// The view (by its `views` key) Presentation was entered from; leaving puts it back.
#[serde(skip)]
pub presentation_view: Option<(u64, View)>,
/// The page in that view and the place on it, for Go Back after leaving Presentation.
#[serde(skip)]
pub presentation_page: Option<(designcraft_doc::ids::PageId, designcraft_geom::Vec2)>,
/// That view's Go Back / Go Forward history; leaving puts it back with one jump added.
#[serde(skip)]
pub(crate) presentation_history: Option<page_history::PageHistory>,
pub frame_edges: bool,
pub rulers: bool,
pub guides: bool,
@@ -371,6 +378,8 @@ impl Default for UiState {
screen_mode: ScreenMode::Normal,
presentation_return: ScreenMode::Normal,
presentation_view: None,
presentation_page: None,
presentation_history: None,
frame_edges: true,
rulers: true,
guides: true,
@@ -572,6 +581,8 @@ pub struct DesignApp {
pub ui: UiState,
pub services: Services,
pub views: HashMap<u64, View>,
/// Layout ▸ Go Back / Go Forward, by view (like `views`).
pub(crate) page_history: HashMap<u64, page_history::PageHistory>,
/// The canvas cache and rect of the pane in `pane` (the other pane's are in `other_pane`).
pub canvas: CanvasCache,
pub canvas_rect: Option<egui::Rect>,
@@ -675,6 +686,7 @@ impl DesignApp {
ui: UiState::default(),
services,
views: HashMap::new(),
page_history: HashMap::new(),
canvas: CanvasCache::new(),
canvas_rect: None,
split: false,
@@ -739,6 +751,13 @@ impl DesignApp {
let k = self.view_key()?;
Some(self.views.entry(k).or_default())
}
pub(crate) fn page_history(&self) -> Option<&page_history::PageHistory> {
self.page_history.get(&self.view_key()?)
}
pub(crate) fn page_history_mut(&mut self) -> Option<&mut page_history::PageHistory> {
let k = self.view_key()?;
Some(self.page_history.entry(k).or_default())
}
/// Make `pane` current: its canvas cache and rect move in.
pub fn switch_pane(&mut self, pane: u8) {
if pane == self.pane {
+302 -3
View File
@@ -251,6 +251,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.nextSpread", "Next Spread", Some("Alt+PageDown"), "{} — centre the spread after the one in view"),
("view.previousSpread", "Previous Spread", Some("Alt+PageUp"), "{} — centre the spread before the one in view"),
("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page: number (position) | \"name\" (section page name, or \"+n\" for a position)}"),
("view.goBack", "Go Back", Some("Cmd+PageUp"), "{} — show the page viewed before this one in the window"),
("view.goForward", "Go Forward", Some("Cmd+PageDown"), "{} — show the page viewed after this one (after Go Back)"),
(
"window.panel",
"Show Panel",
@@ -441,6 +443,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:view.nextSpread",
"ui:view.previousSpread",
"ui:view.goToPage",
"ui:view.goBack",
"ui:view.goForward",
"-",
"cmd:layout.section",
"cmd:toc.generate",
@@ -498,7 +502,11 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
">Notes",
"cmd:note.new",
"cmd:note.convertFromText",
"ui:window.panel|Notes|{\"panel\": \"notes\"}",
"-",
"cmd:note.previous",
"cmd:note.next",
"<",
">Track Changes",
"cmd:changes.track",
@@ -521,6 +529,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
">Text Variables",
"cmd:variables.define",
"cmd:variables.insert",
"cmd:variables.convertToText",
"<",
"-",
">Bulleted & Numbered Lists",
@@ -913,6 +922,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}",
"ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}",
"ui:window.panel|Paragraph|{\"panel\": \"paragraph\"}",
"ui:window.panel|Story|{\"panel\": \"story\"}",
"ui:window.panel|Table|{\"panel\": \"table\"}",
"ui:app.tabsPanel",
"ui:window.panel|Typography|{\"panel\": \"typography\"}",
@@ -966,7 +976,8 @@ pub const CLASSIC_GROUPS: &[&[&str]] = &[
];
/// The Typography preset's available panels in the order observed in InDesign's workspace.
/// CC Libraries and the separate Story panel have no corresponding DesignCraft panel.
/// CC Libraries has no corresponding DesignCraft panel, and where the Story panel sits in it is
/// not recorded, so these groups leave it out.
pub const TYPOGRAPHY_GROUPS: &[&[&str]] = &[
&["pages", "layers", "swatches"],
&["glyphs", "textWrap"],
@@ -1664,6 +1675,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
}
Ok(Value::Null)
}
"view.goBack" | "view.goForward" => {
crate::canvas::go_through_history(app, if id == "view.goBack" { -1 } else { 1 });
Ok(Value::Null)
}
"view.nextSpread" | "view.previousSpread" => {
crate::canvas::step_spread(app, if id == "view.nextSpread" { 1 } else { -1 });
Ok(Value::Null)
@@ -2007,13 +2022,28 @@ fn set_screen_mode(app: &mut DesignApp, mode: ScreenMode) {
app.ui.presentation_return = app.ui.screen_mode;
// Presentation fits the spread to the screen; leaving it puts the user's view back.
app.ui.presentation_view = app.view_key().zip(app.view().copied());
app.ui.presentation_page = crate::canvas::page_in_view(app);
app.ui.presentation_history = app.page_history().cloned();
}
if mode != ScreenMode::Presentation
&& presenting
&& let Some((k, v)) = app.ui.presentation_view.take()
&& let Some(view) = app.views.get_mut(&k)
&& app.views.contains_key(&k)
{
*view = v;
// Go Back sees the presentation as one jump to the last spread presented, and putting the
// view back as a jump from there: the spreads in between would otherwise have replaced the
// place the view was at with the presentation's.
let shown = app.ui.presentation_page.take().zip(crate::canvas::page_in_view(app)).filter(|_| app.view_key() == Some(k));
let h = app.page_history.entry(k).or_default();
*h = app.ui.presentation_history.take().unwrap_or_default();
if let Some(((from, at), (to, offset))) = shown {
h.leave(from, at);
h.visit(to, offset);
h.visit(from, at);
}
if let Some(view) = app.views.get_mut(&k) {
*view = v;
}
}
app.ui.screen_mode = mode;
}
@@ -2506,6 +2536,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) {
return false;
}
if id == "view.goBack" || id == "view.goForward" {
return crate::canvas::can_go_through_history(app, if id == "view.goBack" { -1 } else { 1 });
}
if ui_label(id).is_some() {
return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help.");
}
@@ -4237,6 +4270,272 @@ mod tests {
assert_eq!(v.zoom, zoom);
}
#[test]
fn go_back_and_go_forward_retrace_the_pages_viewed() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let key = |k: egui::Key, modifiers: egui::Modifiers| {
vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
]
};
let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap();
let can = |app: &crate::DesignApp| (menu_enabled(app, "view.goBack"), menu_enabled(app, "view.goForward"));
frame(&mut app, vec![]);
frame(&mut app, vec![]);
// In the Layout menu after Go to Page.
let layout = MENUS.iter().find(|m| m.0 == "Layout").unwrap().1;
let at = |id: &str| layout.iter().position(|e| *e == format!("ui:{id}")).unwrap();
assert_eq!(at("view.goBack"), at("view.goToPage") + 1);
assert_eq!(at("view.goForward"), at("view.goBack") + 1);
assert_eq!(can(&app), (false, false), "one page viewed: nowhere to go");
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
let cmd_shift = egui::Modifiers { shift: true, ..cmd };
let shift = egui::Modifiers::SHIFT;
// Pages viewed: 1, 5, 7.
app.run("view.goToPage", json!({"page": 5})).unwrap();
frame(&mut app, vec![]);
frame(&mut app, key(egui::Key::PageDown, cmd_shift));
assert_eq!(page(&app), 6);
frame(&mut app, vec![]);
assert_eq!(can(&app), (true, false));
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 4, "Go Back");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 0, "Go Back again");
frame(&mut app, vec![]);
assert_eq!(can(&app), (false, true), "back at the first page viewed");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(page(&app), 4, "Go Forward");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(page(&app), 6, "Go Forward again");
frame(&mut app, vec![]);
assert_eq!(can(&app), (true, false));
// After going back, viewing another page drops the pages ahead.
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 4);
frame(&mut app, key(egui::Key::PageDown, shift));
assert_eq!(page(&app), 5, "Next Page");
frame(&mut app, vec![]);
assert_eq!(can(&app), (true, false), "nothing ahead after a new page");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(page(&app), 5, "Go Forward does nothing");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page");
}
#[test]
fn go_back_waits_for_the_view_to_be_fitted() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp| {
let input =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
frame(&mut app);
frame(&mut app);
app.run("view.goToPage", json!({"page": 5})).unwrap();
frame(&mut app);
assert!(menu_enabled(&app, "view.goBack"));
// Leaving parent pages through the Pages panel: the canvas refits on the next frame.
app.view_mut().unwrap().fitted = false;
assert!(!menu_enabled(&app, "view.goBack"), "the refit would replace where Go Back went");
crate::canvas::go_through_history(&mut app, -1);
frame(&mut app);
assert!(menu_enabled(&app, "view.goBack"), "the unfitted Go Back used no history entry");
}
#[test]
fn next_spread_at_the_last_spread_is_no_jump() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let key = |k: egui::Key, modifiers: egui::Modifiers| {
vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
]
};
let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap();
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
let alt = egui::Modifiers::ALT;
frame(&mut app, vec![]);
frame(&mut app, vec![]);
// Pages 1 | 2-3 | 4-5 | 6-7: page 6 is the left page of the last spread.
app.run("view.goToPage", json!({"page": 6})).unwrap();
frame(&mut app, vec![]);
assert_eq!(page(&app), 5);
// Next Spread has no spread to go to; centring the last one moves the view to its fold.
frame(&mut app, key(egui::Key::PageDown, alt));
frame(&mut app, key(egui::Key::PageDown, alt));
assert_ne!(page(&app), 5, "the view moved to the right page");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 0, "Go Back returns to the page before Go to Page");
assert!(!menu_enabled(&app, "view.goBack"));
}
#[test]
fn go_back_returns_to_the_view_a_jump_left_and_scrolling_adds_no_entry() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let key = |k: egui::Key, modifiers: egui::Modifiers| {
vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
]
};
let spread = |app: &crate::DesignApp| {
let st = app.session.active().unwrap();
crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap()
};
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
let alt = egui::Modifiers::ALT;
frame(&mut app, vec![]);
frame(&mut app, vec![]);
// Pages 1 | 2-3 | 4-5 | 6-7. Spread 2-3 fitted: the view centre is on the fold.
frame(&mut app, key(egui::Key::PageDown, alt));
app.run("view.fitSpread", json!({})).unwrap();
frame(&mut app, vec![]);
let before = *app.view().unwrap();
frame(&mut app, key(egui::Key::PageDown, alt));
assert_eq!(spread(&app), 2, "Next Spread");
frame(&mut app, key(egui::Key::PageUp, cmd));
let v = *app.view().unwrap();
assert!((v.origin - before.origin).hypot() < 1e-6, "Go Back shows the spread as it was: {:?} vs {:?}", v.origin, before.origin);
assert_eq!(v.zoom, before.zoom);
// Zoomed in after the jump: Go Back keeps the magnification in use, as InDesign's does, and
// centres the same place.
let centre = |app: &crate::DesignApp| {
let (v, r) = (app.view().unwrap(), app.canvas_rect.unwrap());
designcraft_geom::Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom)
};
let place = centre(&app);
frame(&mut app, key(egui::Key::PageDown, alt));
app.run("view.zoomIn", json!({})).unwrap();
let zoomed = app.view().unwrap().zoom;
assert!(zoomed > before.zoom);
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(app.view().unwrap().zoom, zoomed, "the zoom in use is kept");
assert!((centre(&app) - place).hypot() < 1e-6, "the same place: {:?} vs {place:?}", centre(&app));
// Scrolling down two spreads is not a jump: Go Forward keeps its page, and Go Back skips
// from where the scroll ended straight to the page before the first jump.
let slots = designcraft_tools::layout::CanvasLayout::new(&app.session.active().unwrap().doc, false).slots;
app.view_mut().unwrap().origin.y += slots[3].bounds.center().y - slots[1].bounds.center().y;
frame(&mut app, vec![]);
assert_eq!(spread(&app), 3);
assert!(menu_enabled(&app, "view.goForward"), "scrolling kept the page ahead");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(spread(&app), 0, "Go Back");
assert!(!menu_enabled(&app, "view.goBack"), "the scroll added no entry");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(spread(&app), 3, "Go Forward returns to where the scroll ended");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(spread(&app), 2);
}
#[test]
fn go_back_after_presentation_returns_to_the_last_spread_presented() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let key = |k: egui::Key, modifiers: egui::Modifiers| {
vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
]
};
let spread = |app: &crate::DesignApp| {
let st = app.session.active().unwrap();
crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap()
};
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
frame(&mut app, vec![]);
frame(&mut app, vec![]);
// Scrolled off the page's centre, so the place Go Back returns to is not just the page.
let view = app.view_mut().unwrap();
view.origin.x += 40.0;
view.origin.y += 60.0;
frame(&mut app, vec![]);
assert_eq!(spread(&app), 0);
let before = *app.view().unwrap();
// Presentation goes on to spreads 1 and 2; leaving it puts spread 0 back as it was.
app.run("view.screenMode", json!({"mode": "presentation"})).unwrap();
frame(&mut app, vec![]);
frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE));
frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE));
assert_eq!(spread(&app), 2);
app.run("view.screenMode", json!({"mode": "normal"})).unwrap();
frame(&mut app, vec![]);
assert_eq!(spread(&app), 0);
assert!(!menu_enabled(&app, "view.goForward"));
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(spread(&app), 2, "Go Back shows the last spread presented");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(spread(&app), 0, "Go Back again, the view Presentation was entered from");
let v = *app.view().unwrap();
assert!((v.origin - before.origin).hypot() < 1e-6, "where it was: {:?} vs {:?}", v.origin, before.origin);
assert!(!menu_enabled(&app, "view.goBack"), "the presentation is one jump: spread 1 isn't kept");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(spread(&app), 2, "Go Forward");
}
#[test]
fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+3 -1
View File
@@ -278,11 +278,13 @@ pub(crate) fn end_stale_preview(app: &mut DesignApp) {
}
}
/// Drop the views (one per pane) of a preview document that has gone.
/// Drop the views and page histories (one per pane) of a preview document that has gone.
fn forget_view(app: &mut DesignApp, uid: Option<u64>) {
if let Some(uid) = uid.filter(|uid| app.session.documents().iter().all(|d| d.uid != *uid)) {
app.views.remove(&uid);
app.views.remove(&(uid ^ (1 << 63)));
app.page_history.remove(&uid);
app.page_history.remove(&(uid ^ (1 << 63)));
}
}
+148
View File
@@ -0,0 +1,148 @@
//! Layout ▸ Go Back / Go Forward: the pages a window has jumped between, in the order it showed them.
//! InDesign keeps this order for the session, like a web browser's Back and Forward.
use designcraft_doc::ids::PageId;
use designcraft_geom::Vec2;
/// Entries kept: the oldest go first.
const LIMIT: usize = 256;
#[derive(Clone, Debug, Default)]
pub struct PageHistory {
/// Each page with where the view centre was on it, as an offset from the page's centre.
pages: Vec<(PageId, Vec2)>,
/// The entry in view.
at: usize,
}
impl PageHistory {
/// A jump or Go Back starts from `page`, the view centre `offset` from the page's centre: the
/// entry in view takes that place, wherever scrolling has moved the view since it was recorded,
/// as a browser keeps a page's scroll position.
pub fn leave(&mut self, page: PageId, offset: Vec2) {
match self.pages.get_mut(self.at) {
Some(e) => *e = (page, offset),
None => self.visit(page, offset),
}
}
/// A jump has brought the view to `page`, its centre `offset` from the page's centre. Another page
/// than the entry in view drops the entries ahead of it, as a browser does when a link is followed
/// after going back.
pub fn visit(&mut self, page: PageId, offset: Vec2) {
if let Some(e) = self.pages.get_mut(self.at)
&& e.0 == page
{
e.1 = offset;
return;
}
self.pages.truncate(self.at.saturating_add(1));
self.pages.push((page, offset));
if self.pages.len() > LIMIT {
self.pages.remove(0);
}
self.at = self.pages.len().saturating_sub(1);
}
/// The entry Go Back (`step` < 0) or Go Forward goes to: the nearest one that way whose page is
/// still in the document and isn't the page in view.
fn target(&self, step: isize, exists: impl Fn(PageId) -> bool) -> Option<usize> {
let current = self.pages.get(self.at).map(|e| e.0);
let usable = |i: &usize| self.pages.get(*i).is_some_and(|&(p, _)| Some(p) != current && exists(p));
if step < 0 { (0..self.at).rev().find(usable) } else { (self.at.saturating_add(1)..self.pages.len()).find(usable) }
}
/// Whether Go Back (`step` < 0) or Go Forward has a page to go to.
pub fn can_go(&self, step: isize, exists: impl Fn(PageId) -> bool) -> bool {
self.target(step, exists).is_some()
}
/// Go Back (`step` < 0) or Go Forward: the page to show and where the view was on it. It becomes
/// the entry in view.
pub fn go(&mut self, step: isize, exists: impl Fn(PageId) -> bool) -> Option<(PageId, Vec2)> {
let i = self.target(step, exists)?;
self.at = i;
self.pages.get(i).copied()
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: fn(PageId) -> bool = |_| true;
fn visited(pages: &[u64]) -> PageHistory {
let mut h = PageHistory::default();
for &p in pages {
h.visit(PageId(p), Vec2::ZERO);
}
h
}
fn page(e: Option<(PageId, Vec2)>) -> Option<u64> {
e.map(|(p, _)| p.0)
}
#[test]
fn go_back_and_forward_retrace_the_pages_in_the_order_they_were_viewed() {
let mut h = visited(&[1, 5, 3]);
assert!(!h.can_go(1, ALL), "nothing ahead of the last page viewed");
assert_eq!(page(h.go(-1, ALL)), Some(5));
assert_eq!(page(h.go(-1, ALL)), Some(1));
assert_eq!(h.go(-1, ALL), None, "the first page viewed has nothing before it");
assert_eq!(page(h.go(1, ALL)), Some(5));
assert_eq!(page(h.go(1, ALL)), Some(3));
assert_eq!(h.go(1, ALL), None);
}
#[test]
fn going_back_does_not_record_the_page_it_shows() {
let mut h = visited(&[1, 5, 3]);
let (p, offset) = h.go(-1, ALL).unwrap();
// The next jump starts from it.
h.leave(p, offset);
assert_eq!(page(h.go(1, ALL)), Some(3), "Go Forward still has the page left");
}
#[test]
fn an_entry_keeps_the_place_a_jump_left_it_at() {
let mut h = visited(&[1, 5]);
// Scrolled from page 5 to page 7, then jumped.
h.leave(PageId(7), Vec2::new(-200.0, 30.0));
h.visit(PageId(9), Vec2::ZERO);
assert_eq!(h.go(-1, ALL), Some((PageId(7), Vec2::new(-200.0, 30.0))), "back to where the view was");
assert_eq!(page(h.go(-1, ALL)), Some(1), "scrolling added no entry");
assert_eq!(page(h.go(1, ALL)), Some(7));
assert_eq!(page(h.go(1, ALL)), Some(9), "nor dropped the ones ahead");
}
#[test]
fn viewing_another_page_after_going_back_clears_go_forward() {
let mut h = visited(&[1, 5, 3]);
h.go(-1, ALL);
h.visit(PageId(7), Vec2::ZERO);
assert_eq!(page(h.go(-1, ALL)), Some(5), "the page gone back to stays behind the new one");
assert_eq!(page(h.go(1, ALL)), Some(7));
assert!(!h.can_go(1, ALL), "the page that was ahead is gone");
}
#[test]
fn deleted_pages_are_skipped() {
let mut h = visited(&[1, 5, 3]);
let exists = |p: PageId| p != PageId(5);
assert_eq!(page(h.go(-1, exists)), Some(1));
assert_eq!(page(h.go(1, exists)), Some(3));
assert!(!visited(&[5, 3]).can_go(-1, exists), "no page left to go back to");
}
#[test]
fn the_history_is_bounded() {
let mut h = visited(&(0..LIMIT as u64 + 10).collect::<Vec<_>>());
let mut back = 0;
while h.go(-1, ALL).is_some() {
back += 1;
}
assert_eq!(back, LIMIT - 1);
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod library;
pub mod notes;
pub mod pages;
pub mod properties;
pub mod story;
pub mod styles;
pub mod swatches;
pub mod table;
+20 -3
View File
@@ -2089,9 +2089,10 @@ fn paragraph_panel_menu(app: &mut DesignApp, ui: &mut Ui, p: &Value) {
if item(ui, "Balance Ragged Lines", Some(balance), true) {
attrs = Some(json!({"balanceRagged": !balance}));
}
let optical = p["opticalMargin"].as_bool().unwrap_or(false);
if item(ui, "Optical Margin Alignment", Some(optical), true) {
attrs = Some(json!({"opticalMargin": !optical}));
// Story-wide Optical Margin Alignment is in the Story panel.
let ignore = p["ignoreOpticalMargin"].as_bool().unwrap_or(false);
if item(ui, "Ignore Optical Margin", Some(ignore), true) {
attrs = Some(json!({"ignoreOpticalMargin": !ignore}));
}
ui.separator();
for (label, id) in [
@@ -3451,6 +3452,22 @@ mod tests {
let _ = h.get_by_label("✓ Only Align First Line to Grid");
}
#[test]
fn paragraph_panel_menu_toggles_ignore_optical_margin() {
let mut h = panel_harness(text_app(), paragraph_panel);
h.run_steps(3);
h.get_by_label("☰").click();
h.run_steps(3);
h.get_by_label(" Ignore Optical Margin").click();
h.run_steps(3);
assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["ignoreOpticalMargin"], true);
h.get_by_label("☰").click();
h.run_steps(3);
h.get_by_label("✓ Ignore Optical Margin").click();
h.run_steps(3);
assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["ignoreOpticalMargin"], false);
}
#[test]
fn paragraph_panel_menu_define_lists_opens_its_dialog() {
let mut h = panel_harness(text_app(), paragraph_panel);
+106
View File
@@ -0,0 +1,106 @@
//! Window › Type & Tables › Story: the story-wide options.
use serde_json::{Value, json};
use crate::DesignApp;
use crate::widgets::{self, NumField};
/// The Story panel: Optical Margin Alignment for the stories of the text selection or the
/// selected text frames, and the type size its hang amounts are set for. Without text or a text
/// frame selected, the controls are disabled.
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let options = super::text_attrs(app).map(|a| a["storyOptions"].clone());
let enabled = options.is_some();
let options = options.unwrap_or(Value::Null);
let lang = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
ui.add_enabled_ui(enabled, |ui| {
ui.horizontal_wrapped(|ui| {
// Stories that differ show unchecked; a click turns it on for all of them.
let mut on = options["opticalMargin"].as_bool().unwrap_or(false);
if widgets::checkbox(ui, &mut on, &tr("Optical Margin Alignment")).changed() {
let _ = app.run("story.options", json!({"opticalMargin": on}));
}
let field = NumField::number("storyOpticalMarginSize", options["opticalMarginSize"].as_f64(), " pt", 2)
.width(72.0)
.range(designcraft_doc::OpticalMargin::MIN_SIZE, designcraft_doc::OpticalMargin::MAX_SIZE);
if let Some(size) = crate::chrome::icon_field(ui, "font-size", &tr("Optical Margin Size"), field) {
let _ = app.run("story.options", json!({"opticalMarginSize": size}));
}
});
});
}
#[cfg(test)]
mod tests {
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
use super::*;
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
let mut h = Harness::builder().with_size(egui::vec2(260.0, 200.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let id = egui::Id::new("test_fonts");
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(id, true));
return;
}
show(app, ui);
},
app,
);
h.run_steps(3);
h
}
fn optical_margin(h: &Harness<'static, DesignApp>, story: u64) -> designcraft_doc::OpticalMargin {
h.state().session.active().unwrap().doc.story(designcraft_doc::StoryId(story)).unwrap().optical_margin
}
fn size_field_text(h: &Harness<'static, DesignApp>) -> Option<String> {
h.get_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().value()
}
#[test]
fn story_panel_sets_and_shows_the_storys_optical_margin() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let story =
app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "“Hung”", "caret": true})).unwrap()["story"]
.as_u64()
.unwrap();
app.run("edit.selectAll", json!({})).unwrap();
let mut h = harness(app);
assert_eq!(size_field_text(&h).as_deref(), Some("12 pt"));
h.get_by_label("Optical Margin Alignment").click();
h.run_steps(3);
assert!(optical_margin(&h, story).on);
assert_eq!(h.get_by_label("Optical Margin Alignment").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True));
h.get_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().click();
h.run_steps(2);
h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
h.event(egui::Event::Text("18".into()));
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(optical_margin(&h, story), designcraft_doc::OpticalMargin { on: true, size: 18.0 });
// A value set elsewhere shows in the panel.
h.state_mut().run("story.options", json!({"opticalMarginSize": 30})).unwrap();
h.run_steps(3);
assert_eq!(size_field_text(&h).as_deref(), Some("30 pt"));
}
#[test]
fn story_panel_is_disabled_without_text() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let mut h = harness(app);
assert!(h.get_by_label("Optical Margin Alignment").accesskit_node().is_disabled());
h.get_by_label("Optical Margin Alignment").click();
h.run_steps(3);
assert_eq!(h.get_by_label("Optical Margin Alignment").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False));
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
> **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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 16: Convert Variable to Text; 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.
@@ -177,9 +177,12 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
- **Estimate:** 30–50 h.
### 16. Menu items still missing (U)
- **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes
- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes
- **Missing:** 150 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes
and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference
update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear
Effects / Clear All Transparency / Rotate 180° / Reverse Path,
update commands, Convert Bullets/Numbering to Text, Object ▸ Clear
Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward,
Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to
Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User
+4 -4
View File
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; 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
@@ -204,7 +204,7 @@ table is the one summed.
| Area | Breadth (checklist) | Ready | Hours | Main gaps |
|---|---|---|---|---|
| Application shell & workspace | 99% | 55% | 40–60 | context menus not opening on some systems; window controls (#333); System theme (#237); New Window/Arrange; Help menu |
| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, Go Back/Forward, layout grid (CJK) |
| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, layout grid (CJK) |
| Layers | 100% | 75% | 5–10 | IDML layer order fixed 2026-10-09; Layer panel options depth |
| Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, reference point relocation (#337), Join/Reverse Path/Convert Point menu |
| Transform | 100% | 70% | 10–20 | Rotate 180°; transform panel depth |
@@ -324,7 +324,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place).
- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions.
- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only.
- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported.
- Editorial notes (`note.new/edit/delete/convertToText/convertFromText/list`, Notes panel, Type › Notes › Convert to Note): anchored in text, flagged on screen, never printed or exported.
- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages.
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working colour settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens coloured areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open.
- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection).
@@ -454,7 +454,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Based on Parent (`layout.parents.options {parent, basedOn}`): a parent shows its base parent's items under its own, on its pages and on the parent itself; chains work, cycles are refused, and items from any parent in the chain can be overridden. IDML carries it as the parent pages' `AppliedMaster`; a cycle or missing base in a file is cleared with a warning on open. The Pages panel has no Parent Options dialog yet.
- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position and its character style, separator and its character style, alphabetical sorting and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position and its character style, separator and its character style, alphabetical sorting and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page; Convert Variable to Text turns the selected instances, or every instance of one variable, into the text they show on their page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.
- ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input.
+2 -2
View File
@@ -1,6 +1,6 @@
# Typography and composition parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Optical Margin Alignment per story with a size in the Story panel, and Ignore Optical Margin in the Paragraph panel menu and paragraph styles, kept through IDML (#947); Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6)
Typesetting is what InDesign is bought for. A feature can exist and still set text differently
from InDesign: different line breaks, different page count, overset where InDesign fits. This
@@ -58,7 +58,7 @@ Fidelity: **unmeasured** unless stated.
| Tabs | left/center/right/decimal/align-on, leaders, right-indent tab | ✓ | ~ | Tabs panel with a ruler over the text column, and a Tabs section in Paragraph Style Options (#343); wrapping of lines with tabs fixed (#282) |
| Paragraph rules, borders, shading | ✓ | ✓ | ✓ | |
| Balance ragged lines | ✓ | ✓ | ✓ | |
| Optical margin alignment | ✓ | ✓ (hang table of our own) | ~ | hang amounts are ours, InDesign's unknown |
| Optical margin alignment | per story with a size, Ignore Optical Margin per paragraph and style | ✓ (hang table of our own, scaled by the story's size; Story panel; IDML `StoryPreference`, `IgnoreEdgeAlignment`) | ~ | hang amounts are ours, InDesign's unknown (#947) |
| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Detect Edges, Alpha Channel and Photoshop Path contours follow the frame's path (composition doesn't read the image) |
| Anchored objects | inline, above line, custom | ✓ | ~ | anchored objects in table cells not drawn (#163) |
| Footnotes, endnotes | ✓ | ✓ | ~ | footnotes don't split across columns yet |
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel,
against InDesign 2026. The look was measured from the running app
@@ -47,7 +47,7 @@ basics of editing fail for too many users on Windows and Linux.
| Quick Apply | ✓ | ✓ | ✓ | |
| Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | |
| Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |
| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) |
| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) |
| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies |
| Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) |
| Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing |
@@ -122,6 +122,7 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) |
| 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place |
| 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) |
| 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests |