Merge storytold/main into feat/previous-next-note

This commit is contained in:
Brandon Thomas
2026-10-24 10:23:30 -04:00
55 changed files with 1511 additions and 113 deletions
+6
View File
@@ -359,6 +359,12 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **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.
- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
- **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. 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> {
+38 -9
View File
@@ -731,9 +731,23 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId
let under = spread.and_then(|sp| sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi)));
let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect())));
let g = &doc.settings.baseline_grid;
let (start, inc) = tf.options.baseline_grid.map_or((under.map_or(g.start, |pg| g.origin(pg)), g.increment), |f| (f.start, f.increment));
// Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only).
let ty = xf.translation().y;
let (start, inc) = match tf.options.baseline_grid {
None => (under.map_or(g.start, |pg| g.origin(pg)), g.increment),
Some(f) => {
use designcraft_doc::FrameGridRelative as R;
// The frame's own edges are in inner space; `ty` takes them to spread y as it takes
// the grid back, so a frame-relative grid keeps its place in a turned frame.
let from = match f.relative_to {
R::TopOfPage => 0.0,
R::TopMargin => under.map_or(0.0, |pg| pg.margins.top),
R::TopOfFrame => ty + item.inner_bounds().y0,
R::TopInset => ty + item.text_area().y0,
};
(from + f.start, f.increment)
}
};
let grid = (inc > 0.0 && inc.is_finite() && start.is_finite()).then_some((start - ty, inc));
// Type on a path: one line as long as the path (from the start offset).
let (area, opts) = match &tf.options.path {
@@ -1049,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() {
@@ -1122,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.
@@ -1278,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)))
@@ -2289,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,
@@ -2308,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,
@@ -3434,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();
+82 -1
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.
@@ -6697,7 +6747,7 @@ fn roman_word_break_breaks_inside_words_without_hyphens() {
fn grid_doc(text: &str, para: ParaAttrs, edit: impl FnOnce(&mut Document, StoryId)) -> (Vec<Line>, (f64, f64)) {
let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), para);
if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) {
tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { start: 0.0, increment: 12.0 });
tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, 12.0));
}
edit(&mut d, sid);
// Vertical frames have no baseline grid.
@@ -7407,6 +7457,37 @@ fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() {
assert_eq!(lines.len(), 41);
}
/// A frame's custom grid (start 10, increment 13) measured from the page top, the top margin
/// (36), the frame top (100) or the top inset (6 below it).
#[test]
fn frame_baseline_grid_relative_to_page_margin_frame_or_inset() {
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative as R};
let first_baseline = |relative_to: Option<R>| {
let mut d = Document::new(&NewDocument { margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() });
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 100.0, 336.0, 400.0), lid, "Grid", para).unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].chars =
designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() };
let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap();
tf.options.inset = [6.0, 0.0, 0.0, 0.0];
tf.options.baseline_grid = relative_to.map(|r| FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(10.0, 13.0) });
// A document grid that matches none of the frame grids.
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 0.0;
compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline
};
// Without a grid the first baseline sits an ascent below the top inset.
let natural = first_baseline(None);
assert!(natural > 106.0 && natural < 120.0, "{natural}");
for (r, origin) in [(R::TopOfPage, 10.0), (R::TopMargin, 46.0), (R::TopOfFrame, 110.0), (R::TopInset, 116.0)] {
let want = (0..).map(|n| origin + 13.0 * f64::from(n)).find(|y| *y >= natural - 1e-9).unwrap();
let got = first_baseline(Some(r));
assert!((got - want).abs() < 1e-6, "{r:?}: first baseline {got}, want {want} (natural {natural})");
}
}
/// A group's transform moves a frame from the left page of a spread to the right one: the grid
/// starts at the right page's top margin.
#[test]
+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
+87 -11
View File
@@ -458,25 +458,87 @@ pub struct TextFrameOptions {
pub path: Option<PathType>,
}
/// A text frame's custom baseline grid. Saved as `[start, increment]`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "(f64, f64)", into = "(f64, f64)")]
/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the
/// top of the page, otherwise as `[start, increment, relativeTo]`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameBaselineGrid {
/// The first grid line, down from the top of the page.
/// The first grid line, down from the line `relative_to` names.
pub start: f64,
/// The distance between grid lines; 0 or less turns the grid off.
pub increment: f64,
pub relative_to: FrameGridRelative,
}
impl From<(f64, f64)> for FrameBaselineGrid {
fn from((start, increment): (f64, f64)) -> Self {
FrameBaselineGrid { start, increment }
impl FrameBaselineGrid {
/// A grid measured from the top of the page.
pub fn new(start: f64, increment: f64) -> Self {
FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage }
}
}
impl From<FrameBaselineGrid> for (f64, f64) {
fn from(g: FrameBaselineGrid) -> Self {
(g.start, g.increment)
/// What a frame's custom baseline grid's start is measured from.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FrameGridRelative {
#[default]
#[serde(rename = "page")]
TopOfPage,
#[serde(rename = "margin")]
TopMargin,
#[serde(rename = "frame")]
TopOfFrame,
#[serde(rename = "inset")]
TopInset,
}
impl FrameGridRelative {
pub const ALL: [FrameGridRelative; 4] =
[FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset];
/// The name commands and native files use: page, margin, frame or inset.
pub fn name(self) -> &'static str {
match self {
FrameGridRelative::TopOfPage => "page",
FrameGridRelative::TopMargin => "margin",
FrameGridRelative::TopOfFrame => "frame",
FrameGridRelative::TopInset => "inset",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|r| r.name() == name)
}
}
impl Serialize for FrameBaselineGrid {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
// Files saved before Relative To existed hold two elements; a page-relative grid keeps that form.
match self.relative_to {
FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s),
r => (self.start, self.increment, r).serialize(s),
}
}
}
impl<'de> Deserialize<'de> for FrameBaselineGrid {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Grid;
impl<'de> serde::de::Visitor<'de> for Grid {
type Value = FrameBaselineGrid;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("[start, increment] or [start, increment, relativeTo]")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Self::Value, A::Error> {
use serde::de::Error;
let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?;
let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?;
let relative_to = a.next_element()?.unwrap_or_default();
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(4, &self));
}
Ok(FrameBaselineGrid { start, increment, relative_to })
}
}
d.deserialize_seq(Grid)
}
}
@@ -1686,10 +1748,24 @@ mod tests {
#[test]
fn frame_baseline_grid_saves_as_start_then_increment() {
// A file saved before Relative To existed: the grid starts from the top of the page and
// saves back unchanged.
let v = serde_json::json!({"baselineGrid": [6.0, 14.0]});
let o = serde_json::from_value::<TextFrameOptions>(v).unwrap();
assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0 }));
assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0, relative_to: FrameGridRelative::TopOfPage }));
assert_eq!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0]));
for r in [FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset] {
let o = TextFrameOptions {
baseline_grid: Some(FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(6.0, 14.0) }),
..Default::default()
};
let v = serde_json::to_value(&o).unwrap();
assert_eq!(v["baselineGrid"], serde_json::json!([6.0, 14.0, r.name()]));
assert_eq!(serde_json::from_value::<TextFrameOptions>(v).unwrap().baseline_grid, o.baseline_grid);
}
for bad in [serde_json::json!([6.0]), serde_json::json!([6.0, 14.0, "frame", 1]), serde_json::json!([6.0, 14.0, "spine"])] {
assert!(serde_json::from_value::<TextFrameOptions>(serde_json::json!({"baselineGrid": bad})).is_err());
}
}
#[test]
+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");
+115 -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));
@@ -114,6 +123,55 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// 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"))?;
@@ -264,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);
}
}
+106 -2
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
),
@@ -598,7 +598,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Frame Options…",
["Object"],
Some("Cmd+B"),
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
has_selection,
text_frame_options
),
@@ -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 };
@@ -2506,6 +2514,41 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
as u8,
),
};
// `Some(None)` turns the frame's custom baseline grid off.
let custom_grid = match p.get("baselineGrid") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Object(g)) => {
let num = |key: &str, lo: f64, hi: f64| match g.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)),
_ => Err(bad(ID, format!("baselineGrid.{key} must be a number of points from {lo} to {hi}, not {v}"))),
},
};
let start = num("start", -15552.0, 15552.0)?;
let increment = num("increment", 0.0, 15552.0)?;
let relative_to = match g.get("relativeTo") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(designcraft_doc::FrameGridRelative::from_name)
.ok_or_else(|| bad(ID, format!("baselineGrid.relativeTo must be page|margin|frame|inset, not {v}")))?,
),
};
Some(Some((start, increment, relative_to)))
}
Some(v) => return Err(bad(ID, format!("baselineGrid must be an object or null, not {v}"))),
};
// A frame without a custom grid starts from the document's.
let doc_grid = {
let g = &st.doc.settings.baseline_grid;
let relative_to = match g.relative_to {
designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage,
designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin,
};
designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to }
};
let rule_color = match p.get("columnRuleColor") {
None | Some(Value::Null) => None,
Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()),
@@ -2596,6 +2639,17 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = rule_bottom {
o.column_rule_bottom_inset = v;
}
match custom_grid {
Some(Some((start, increment, relative_to))) => {
let mut g = o.baseline_grid.unwrap_or(doc_grid);
g.start = start.unwrap_or(g.start);
g.increment = increment.unwrap_or(g.increment);
g.relative_to = relative_to.unwrap_or(g.relative_to);
o.baseline_grid = Some(g);
}
Some(None) => o.baseline_grid = None,
None => {}
}
// Fixed Width columns set the frame's width: n columns, the gutters between them and
// the left and right insets.
let fixed_width = o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth && o.column_width > 0.0;
@@ -4099,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)]
@@ -5609,3 +5683,33 @@ mod fill_tint_tests {
assert!(stroke_of(&s, b).is_none());
}
}
#[cfg(test)]
mod frame_baseline_grid_tests {
use super::*;
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative};
#[test]
fn text_frame_options_set_a_custom_baseline_grid_relative_to() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let grid = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).and_then(|t| t.options.baseline_grid);
// Turning it on with only Relative To takes the document grid's start and increment.
let doc = s.doc().unwrap().doc.settings.baseline_grid.clone();
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "frame"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: doc.start, increment: doc.increment, relative_to: FrameGridRelative::TopOfFrame }));
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"start": 10, "increment": 13, "relativeTo": "inset"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopInset }));
// Fields not given keep the frame's.
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "margin"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopMargin }));
for bad in [json!({"relativeTo": "spine"}), json!({"start": f64::MAX}), json!({"increment": -1}), json!(3)] {
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": bad})).is_err(), "{bad}");
}
assert_eq!(grid(&s).map(|g| g.relative_to), Some(FrameGridRelative::TopMargin));
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap();
assert_eq!(grid(&s), None);
}
}
+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::*;
+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));
+15 -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");
@@ -1949,6 +1950,15 @@ impl<'a> Ex<'a> {
El::new("BaselineFrameGridOption")
.attr("UseCustomBaselineFrameGrid", "true")
.attr("StartingOffsetForBaselineFrameGrid", num(g.start))
.attr(
"BaselineFrameGridRelativeOption",
match g.relative_to {
designcraft_doc::FrameGridRelative::TopOfPage => "TopOfPageOfBaselineFrameGridRelativeOption",
designcraft_doc::FrameGridRelative::TopMargin => "TopOfMarginOfBaselineFrameGridRelativeOption",
designcraft_doc::FrameGridRelative::TopOfFrame => "TopOfFrameOfBaselineFrameGridRelativeOption",
designcraft_doc::FrameGridRelative::TopInset => "TopOfInsetOfBaselineFrameGridRelativeOption",
},
)
.attr("BaselineFrameGridIncrement", num(g.increment)),
);
}
@@ -2217,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(
+24
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,
@@ -2977,6 +2987,7 @@ impl<'r> Importer<'r> {
options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid {
start: g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0),
increment: g.num("BaselineFrameGridIncrement").unwrap_or(12.0),
relative_to: g.get("BaselineFrameGridRelativeOption").map(frame_grid_relative).unwrap_or_default(),
});
}
it.content = Content::Text(TextFrame { story, options });
@@ -3518,6 +3529,19 @@ fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Colu
columns
}
/// A `BaselineFrameGridRelativeOption` value (`TopOfInsetOfBaselineFrameGridRelativeOption`, or a
/// short form such as `TopOfInset`); unknown values measure from the top of the page.
fn frame_grid_relative(v: &str) -> designcraft_doc::FrameGridRelative {
use designcraft_doc::FrameGridRelative as R;
let v = v.strip_suffix("OfBaselineFrameGridRelativeOption").unwrap_or(v);
match v {
"TopOfMargin" | "TopMargin" => R::TopMargin,
"TopOfFrame" => R::TopOfFrame,
"TopOfInset" | "TopInset" => R::TopInset,
_ => R::TopOfPage,
}
}
/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it.
fn text_frame_options(prefs: &[&El]) -> TextFrameOptions {
let e = Prefs(prefs);
+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() {
+29 -2
View File
@@ -6,6 +6,12 @@ const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/pack
/// 14 pt increment; its four paragraphs align to the grid. The frame's top is the page's top, so
/// the grid lines are 6 + n·14 pt down from the frame top whatever the grid is measured from.
fn frame_grid_doc() -> Document {
frame_grid_doc_relative(None)
}
/// [`frame_grid_doc`] with `BaselineFrameGridRelativeOption` set to `relative` when given.
fn frame_grid_doc_relative(relative: Option<&str>) -> Document {
let relative = relative.map(|r| format!(r#" BaselineFrameGridRelativeOption="{r}""#)).unwrap_or_default();
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document {NS} Self="d" StoryList="s1" Name="grid.indd"><idPkg:Graphic src="Resources/Graphic.xml"/><idPkg:Styles src="Resources/Styles.xml"/><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1" Visible="true" Locked="false"/><idPkg:Spread src="Spreads/Spread_sp1.xml"/><idPkg:Story src="Stories/Story_s1.xml"/></Document>"#
@@ -17,7 +23,7 @@ fn frame_grid_doc() -> Document {
.collect();
let spread = format!(
r#"<idPkg:Spread {NS}><Spread Self="sp1" PageCount="1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 600 400" ItemTransform="1 0 0 1 0 -300"/>
<TextFrame Self="t1" ParentStory="s1" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 -300"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties><TextFramePreference InsetSpacing="0"/><BaselineFrameGridOption UseCustomBaselineFrameGrid="true" StartingOffsetForBaselineFrameGrid="6" BaselineFrameGridIncrement="14"/></TextFrame>
<TextFrame Self="t1" ParentStory="s1" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 -300"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties><TextFramePreference InsetSpacing="0"/><BaselineFrameGridOption UseCustomBaselineFrameGrid="true" StartingOffsetForBaselineFrameGrid="6" BaselineFrameGridIncrement="14"{relative}/></TextFrame>
</Spread></idPkg:Spread>"#
);
let story = format!(
@@ -46,7 +52,7 @@ fn baselines(d: &Document) -> Vec<f64> {
fn custom_frame_grid_sets_lines_from_its_start_by_its_increment() {
let d = frame_grid_doc();
let grid = d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid;
assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid { start: 6.0, increment: 14.0 }));
assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid::new(6.0, 14.0)));
let b = baselines(&d);
assert_eq!(b.len(), 4, "{b:?}");
for y in &b {
@@ -69,3 +75,24 @@ fn custom_frame_grid_round_trips() {
assert_eq!(grid(&back), grid(&d));
assert_eq!(baselines(&back), baselines(&d));
}
#[test]
fn custom_frame_grid_relative_to_round_trips() {
use designcraft_doc::FrameGridRelative as R;
let relative = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid.map(|g| g.relative_to);
for (attr, want) in [
("TopOfPageOfBaselineFrameGridRelativeOption", R::TopOfPage),
("TopOfMarginOfBaselineFrameGridRelativeOption", R::TopMargin),
("TopOfFrameOfBaselineFrameGridRelativeOption", R::TopOfFrame),
("TopOfInsetOfBaselineFrameGridRelativeOption", R::TopInset),
("TopOfInset", R::TopInset),
("SomethingNew", R::TopOfPage),
] {
let d = frame_grid_doc_relative(Some(attr));
assert_eq!(relative(&d), Some(want), "{attr}");
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(relative(&back), Some(want), "{attr} after export");
}
// A frame without the attribute measures from the top of the page.
assert_eq!(relative(&frame_grid_doc()), Some(R::TopOfPage));
}
+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() {
+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);
+165
View File
@@ -527,6 +527,15 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
_ => ((inner - o.gutter * (n - 1.0)) / n).max(0.0),
};
let kind = |v: Value| v.as_str().unwrap_or_default().to_string();
// A frame without a custom baseline grid shows the document grid's start and increment.
let grid = o.baseline_grid.unwrap_or_else(|| {
let g = app.session.active().map(|s| s.doc.settings.baseline_grid.clone()).unwrap_or_default();
let relative_to = match g.relative_to {
designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage,
designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin,
};
designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to }
});
let shown = json!({
"columns": o.columns, "gutter": fm(o.gutter),
"columnsKind": kind(serde_json::to_value(o.columns_kind).unwrap_or_default()), "columnWidth": fm(width),
@@ -542,6 +551,8 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
"useMinimumHeight": o.auto_size_use_min_height, "minimumHeight": fm(o.auto_size_min_height),
"useMinimumWidth": o.auto_size_use_min_width, "minimumWidth": fm(o.auto_size_min_width),
"noLineBreaks": o.auto_size_no_line_breaks,
"customGrid": o.baseline_grid.is_some(), "gridStart": fm(grid.start), "gridIncrement": fm(grid.increment),
"gridRelativeTo": grid.relative_to.name(),
});
if let Value::Object(m) = &shown {
for (k, v) in m {
@@ -593,6 +604,28 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog)
text_field(ui, d, "firstBaselineMin", 70.0);
ui.end_row();
});
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(tr("Baseline Grid")).font(semibold(12.0)));
check(ui, d, "customGrid", tr("Use Custom Baseline Grid"));
ui.add_enabled_ui(d.b("customGrid"), |ui| {
egui::Grid::new("tfo_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, tr("Start:"));
text_field(ui, d, "gridStart", 70.0);
ui.end_row();
crate::rtl::label(ui, tr("Relative To:"));
translated_combo(
&lang,
ui,
d,
"gridRelativeTo",
&[("page", "Top of Page"), ("margin", "Top Margin"), ("frame", "Top of Frame"), ("inset", "Top Inset")],
);
ui.end_row();
crate::rtl::label(ui, tr("Increment Every:"));
text_field(ui, d, "gridIncrement", 70.0);
ui.end_row();
});
});
}
"autosize" => auto_size(&lang, ui, d),
"rules" => column_rules(app, ui, d),
@@ -857,6 +890,30 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Val
p.insert(key.into(), json!(d.m(key).filter(|v| *v >= 0.0).ok_or_else(|| bad(what))?));
}
}
// The custom baseline grid: off, or the fields the user changed (all of them when it is
// turned on, since it shows the document grid's).
let grid_keys = ["customGrid", "gridStart", "gridIncrement", "gridRelativeTo"];
if grid_keys.iter().any(|k| edited(d, k)) {
if d.b("customGrid") {
let all = edited(d, "customGrid");
let mut g = Map::new();
if all || edited(d, "gridStart") {
g.insert("start".into(), json!(d.m("gridStart").ok_or_else(|| bad("the baseline grid start is not a measure"))?));
}
if all || edited(d, "gridIncrement") {
g.insert(
"increment".into(),
json!(d.m("gridIncrement").filter(|v| *v > 0.0).ok_or_else(|| bad("the baseline grid increment is not a measure"))?),
);
}
if all || edited(d, "gridRelativeTo") {
g.insert("relativeTo".into(), json!(d.s("gridRelativeTo")));
}
p.insert("baselineGrid".into(), Value::Object(g));
} else {
p.insert("baselineGrid".into(), Value::Null);
}
}
if p.is_empty() {
return Ok(Value::Null);
}
@@ -3981,7 +4038,32 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
ui.end_row();
}
// The CJK Grid Settings section edits the same `p.gridAlign` field.
use designcraft_doc::GridAlign;
const GRID_ALIGN: [(GridAlign, &str); 3] =
[(GridAlign::None, "None"), (GridAlign::AllLines, "All Lines"), (GridAlign::FirstLineOnly, "First Line Only")];
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align to Grid:"));
let g: GridAlign = serde_json::from_value(cur(d, "p.gridAlign", &pv["gridAlign"])).unwrap_or_default();
let shown = GRID_ALIGN.iter().find(|o| o.0 == g).map_or("", |o| o.1);
egui::ComboBox::from_id_salt("psgridalign")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, shown)))
.width(240.0)
.show_ui(ui, |ui| {
for (v, label) in GRID_ALIGN {
if ui.selectable_label(v == g, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked()
&& let Ok(v) = serde_json::to_value(v)
{
d.fields.insert("p.gridAlign".into(), v);
}
}
});
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),
@@ -7790,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();
@@ -8303,6 +8414,35 @@ mod tests {
assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0));
}
#[test]
fn text_frame_options_set_a_custom_baseline_grid_relative_to() {
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative};
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
let grid = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.baseline_grid;
let open = |app: &mut DesignApp, fields: &[(&str, Value)]| {
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(app, &mut d);
for (k, v) in fields {
d.fields.insert((*k).into(), v.clone());
}
app.ui.dialog = Some(d);
confirm(app)
};
app.run("selection.set", json!({"ids": [id.0]})).unwrap();
// Turned on, the grid takes the shown start and increment (the document grid's: 3p0 and 1p0).
open(&mut app, &[("customGrid", json!(true)), ("gridRelativeTo", json!("frame"))]).unwrap();
assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 36.0, increment: 12.0, relative_to: FrameGridRelative::TopOfFrame }));
// The dialog shows the frame's grid; changing Relative To keeps its start and increment.
open(&mut app, &[("gridStart", json!("0p10"))]).unwrap();
open(&mut app, &[("gridRelativeTo", json!("inset"))]).unwrap();
assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 10.0, increment: 12.0, relative_to: FrameGridRelative::TopInset }));
open(&mut app, &[("customGrid", json!(false))]).unwrap();
assert_eq!(grid(&app), None);
}
#[test]
fn text_frame_options_change_only_what_was_edited() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -9658,6 +9798,31 @@ mod tests {
assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center));
}
#[test]
fn paragraph_style_options_align_to_grid_shows_and_sets_the_style_grid_alignment() {
use designcraft_doc::GridAlign;
use egui_kittest::kittest::Queryable;
let mut app = test_app();
app.session.execute("style.paragraph.create", &json!({"name": "Grid Body", "para": {"gridAlign": "firstLineOnly"}})).unwrap();
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"})));
let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0));
let grid = |h: &egui_kittest::Harness<'static, crate::test_window::Window>| {
h.state().app.session.active().unwrap().doc.styles.para("Grid Body").unwrap().para.grid_align
};
h.run_steps(4);
h.get_by_value("First Line Only");
confirm(&mut h.state_mut().app).unwrap();
assert_eq!(grid(&h), Some(GridAlign::FirstLineOnly), "OK without edits keeps the style's alignment");
h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"})));
h.run_steps(4);
h.get_by_value("First Line Only").click();
h.run_steps(3);
h.get_by_label("All Lines").click();
h.run_steps(3);
confirm(&mut h.state_mut().app).unwrap();
assert_eq!(grid(&h), Some(GridAlign::AllLines));
}
#[test]
fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() {
let mut app = test_app();
+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": {}}));
+1
View File
@@ -1774,6 +1774,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…", "角选项…"]),
+1
View File
@@ -1378,6 +1378,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…", "إنشاء تخطيط بديل…"),
+1
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ů…"),
+1
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 …"),
+1
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…"),
+1
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…", "角のオプション…"),
+1
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…", "კუთხეების პარამეტრები…"),
+1
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…"),
+1
View File
@@ -1515,6 +1515,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"),
+1
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…", "Параметры углов…"),
+1
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…", "Параметри кутів…"),
+4 -1
View File
@@ -498,6 +498,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
">Notes",
"cmd:note.new",
"cmd:note.convertFromText",
"ui:window.panel|Notes|{\"panel\": \"notes\"}",
"-",
"cmd:note.previous",
@@ -916,6 +917,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\"}",
@@ -969,7 +971,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"],
+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));
}
}
+1 -1
View File
@@ -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).
+3 -3
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
@@ -46,7 +46,7 @@ Fidelity: **unmeasured** unless stated.
| Leading | auto (120%), absolute, leading model | ✓ | ✓ | CJK leading models in cjk-typography.md |
| First baseline | ascent, cap height, x-height, leading, fixed, min | ✓ | ✓ | |
| Frame fit / overset | baseline of the last line must fit | descenders must fit | ~ | **differs** (#180): frames go overset where InDesign fits |
| Baseline grid | document grid relative to the top of the page or each page's top margin; frame grids | ✓ | ✓ | |
| Baseline grid | document grid relative to the top of the page or each page's top margin; frame grids relative to the top of the page, top margin, top of frame or top inset | ✓ | ✓ | |
| Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | |
| Text in shaped frames | lines follow oval, polygon and freeform frame outlines, one inset from the outline | ✓ | ~ | concave shapes set one slot per line (the widest); vertical justification and paragraph shading use the frame's rectangle (#646) |
| Columns, span / split columns | ✓ | ✓ | ✓ | split columns within a column (#295); text after a span flows below it (#276) |
@@ -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 |