Merge pull request #1709 from aiongg/w65-batch

Integration batch: book-chapter IDML baseline grids, hyphenation, and the Paragraph panel stack
This commit is contained in:
aiongg
2026-10-24 08:14:29 -04:00
committed by GitHub
37 changed files with 3515 additions and 566 deletions
+11 -6
View File
@@ -358,11 +358,17 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **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.
- **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.
- **2026-10-24** · Text frames: a custom baseline grid (Text Frame Options › Baseline Options) from an IDML file sets lines from its start by its increment. The composer read the two values in the other order, so lines landed on the wrong grid. No readiness percentage change.
- **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change.
- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.
- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change.
- **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change.
- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
@@ -380,6 +386,11 @@ Newest first.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
@@ -387,14 +398,8 @@ Newest first.
- **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change.
- **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change.
- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change.
- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
+16 -3
View File
@@ -186,7 +186,7 @@ impl Cache {
}
// Advanced Type sizes super/subscripts.
let a = doc.settings.advanced_type;
sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].map(|v| v.to_bits() as usize));
sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].into_iter().flat_map(bits));
// Missing glyphs: fallback fonts or the font's box.
sig.push(usize::from(doc.settings.glyph_fallback));
// Named lists continue from earlier stories.
@@ -338,6 +338,13 @@ impl Cache {
}
}
/// Both halves of `v`'s bits: a 32-bit `usize` (wasm) would drop the high half, where whole
/// numbers keep their differences.
fn bits(v: f64) -> [usize; 2] {
let b = v.to_bits();
[(b & 0xffff_ffff) as usize, (b >> 32) as usize]
}
fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>) {
// The document's font scope: another document's fonts of the same name set it differently.
// Sections name the pages under the frames (page-number and section markers).
@@ -348,9 +355,9 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
Arc::as_ptr(&doc.styles) as usize,
sections_hash(doc) as usize,
doc.font_scope as usize,
g.start.to_bits() as usize,
g.increment.to_bits() as usize,
usize::from(g.relative_to == designcraft_doc::GridRelative::TopMargin),
];
sig.extend([g.start, g.increment].into_iter().flat_map(bits));
let mut keep = Vec::new();
let mut spreads = Vec::new();
for f in &story.frames {
@@ -373,6 +380,12 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
sig.push(loc.top());
if !spreads.contains(&loc.spread) {
spreads.push(loc.spread);
// The page under a frame (found by x) gives its grid origin, page rect and side.
for p in &sp.pages {
let m = &p.margins;
sig.extend([p.x, p.width, p.height, m.top, m.bottom, m.inside, m.outside].into_iter().flat_map(bits));
sig.push(p.side as usize);
}
for it in &sp.items {
let mut wraps = false;
it.walk(&mut |i| wraps |= i.has_wrap());
+109 -21
View File
@@ -709,19 +709,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
let left_page = side == Some(designcraft_doc::PageSide::Left);
let right_page = side == Some(designcraft_doc::PageSide::Right);
let section_marker = page.map(|abs| section_marker(doc, abs));
// The page under the frame and its margins, in inner space (bounding boxes).
let page_rect = match (spread_ref, spread) {
(_, Some(sp)) => sp
.page_at_x(center_x)
.and_then(|pi| sp.pages.get(pi))
.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))),
_ => None,
};
// The page under the frame, and its bounds and margins in inner space (bounding boxes).
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 (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start));
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 grid = (inc > 0.0).then_some((start - ty, inc));
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 {
Some(pt) => {
@@ -868,6 +863,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut info: Vec<ParaInfo> = Vec::with_capacity(np);
let mut force_col = vec![false; np];
let mut line_cap: Vec<Option<usize>> = vec![None; np];
// Hyphenate Across Column off: the hyphenation points (story bytes) a paragraph is re-laid
// without, because a line ending at one of them ended a column or frame. Each re-lay leaves
// out one more point of the paragraph, so they are bounded by its points; the cap only guards
// against keep options clearing them over and over.
let mut no_hyph: Vec<Vec<usize>> = vec![Vec::new(); np];
let mut hyph_relays = 0usize;
let mut restores = 0usize;
let mut pi = 0;
let var_story = vars::has_vars(story);
@@ -907,7 +908,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&& let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget)
{
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -917,7 +918,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&& let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget)
{
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -971,7 +972,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -1106,7 +1107,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
apply_desired_spacing(&mut gl, &pp);
mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi);
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let mut hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
suppress_hyphens(&mut hy, &gl, no_hyph.get(pi).map_or(&[], Vec::as_slice));
let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
let tab = |j: usize, x: f64, i: usize| {
let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 };
@@ -1241,7 +1243,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
bidi::resolve_mirroring(&mut glyphs, &bidi_info);
apply_desired_spacing(&mut glyphs, &pp);
mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let mut hyph_after =
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);
// An empty paragraph takes its height from its paragraph mark's character format.
@@ -1348,7 +1352,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !t.done {
trial = Some(t);
balance_runs += 1;
rewind(key.1, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
key.1,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = key.1;
continue 'paras;
}
@@ -1384,6 +1399,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut line_no = 0usize;
// Paragraph line number of the first line in the current column.
let mut col_first_line = 0usize;
// The story byte at which the last line set ended at an automatic hyphenation point.
let mut hyphen_at: Option<usize> = None;
let has_tabs = glyphs.iter().any(|g| g.ch == '\t');
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft;
@@ -1401,7 +1418,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if cur.fi >= frames.len() {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -1628,6 +1645,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
0.0
};
let fits = baseline + fit_descent <= col.y1 - reserve + 0.01 && !capped;
if !fits
&& !pp.hyph_across_column
&& line_no > col_first_line
&& let Some(at) = hyphen_at
&& hyph_relays < story.text.len().saturating_add(64).saturating_mul(restores.saturating_add(1))
{
hyph_relays += 1;
no_hyph[pi].push(at);
rewind(pi, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
continue 'paras;
}
if !fits {
if cur.can_next_sub() {
g0 = s;
@@ -1649,7 +1677,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
if let Some(j) = j {
split_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = j;
continue 'paras;
}
@@ -1682,7 +1721,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
j
}
};
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = j;
continue 'paras;
}
@@ -1794,7 +1844,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r)
&& let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para)
{
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = j;
continue 'paras;
}
@@ -1826,6 +1887,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.last_descent = desc;
cur.pending = 0.0;
cur.grid_next = grid_end.map(|end| (baseline, end));
hyphen_at =
e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len);
line_no += 1;
// A column / frame / page break inside the paragraph: the rest of it continues in the
// new column/frame/page, re-broken there. (A break that ends the paragraph moves the
@@ -1953,7 +2016,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !t.done {
split_trial = Some(t);
split_runs += 1;
rewind(key.2, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
key.2,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = key.2;
continue 'paras;
}
@@ -2318,9 +2392,11 @@ fn rewind(
list_counter: &mut ListCounter,
force_col: &mut [bool],
line_cap: &mut [Option<usize>],
no_hyph: &mut [Vec<usize>],
) {
force_col.iter_mut().skip(j + 1).for_each(|f| *f = false);
line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None);
no_hyph.iter_mut().skip(j + 1).for_each(Vec::clear);
if let Some(s) = snaps.get(j) {
s.restore(out, cur, list_counter);
}
@@ -4020,6 +4096,18 @@ fn foreign_ranges(
.collect()
}
/// Leave out the hyphenation points after the glyphs that end at story bytes `at`.
fn suppress_hyphens(hy: &mut [bool], glyphs: &[Glyph], at: &[usize]) {
if at.is_empty() {
return;
}
for (h, g) in hy.iter_mut().zip(glyphs) {
if *h && at.contains(&(g.byte + g.len)) {
*h = false;
}
}
}
fn hyphenation_points(
text: &str,
glyphs: &[Glyph],
+208 -1
View File
@@ -1013,6 +1013,92 @@ fn hyphenation_zone_limits_ragged_hyphens() {
assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}");
}
#[test]
fn hyphenate_across_column_off_ends_no_column_with_a_hyphen() {
let text = CORPUS.join(" ");
// Column-end lines that are hyphenated, over frame heights that end the first column at each
// line in turn.
let ends = |across: bool, composer: designcraft_doc::Composer| {
let mut ends = 0;
let mut lines = 0;
for h in (30..400).step_by(3) {
let para = ParaAttrs {
align: Some(Align::Left),
hyph_weight: Some(0.0),
hyph_across_column: Some(across),
composer: Some(composer),
..Default::default()
};
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 240.0, h as f64), para);
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
let cs = compose_story(&d, sid, &ComposeOptions::default());
let col0: Vec<&Line> = all_lines(&cs).into_iter().filter(|l| l.column == 0).collect();
ends += usize::from(col0.last().is_some_and(|l| l.hyphenated));
lines += col0.len();
// The frame's last line too, when the text goes on (overset).
let last = all_lines(&cs).into_iter().last();
ends += usize::from(cs.is_overset() && last.is_some_and(|l| l.hyphenated && !l.last_in_para));
}
(ends, lines)
};
for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
let (on, lines_on) = ends(true, c);
let (off, lines_off) = ends(false, c);
// With it on, columns do end in hyphens here; with it off, none does, and the columns
// still fill.
assert!(on > 3, "{c:?}: {on}");
assert_eq!(off, 0, "{c:?}");
assert!(lines_off * 10 >= lines_on * 9, "{c:?}: {lines_off} {lines_on}");
}
}
#[test]
fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() {
// The first line in a character style of another colour (the same widths): when its hyphen is
// left out at a column end, the style still covers exactly the first line as set.
let text = CORPUS.join(" ");
let compose = |w: f64, h: f64, across: bool| {
let para = ParaAttrs {
align: Some(Align::Left),
hyph_weight: Some(0.0),
hyph_across_column: Some(across),
nested_line_styles: Some(vec![designcraft_doc::NestedLineStyle { style: "Lead".into(), lines: 1 }]),
..Default::default()
};
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, w, h), para);
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
let chars = designcraft_doc::CharAttrs { fill: Some("[Registration]".into()), ..Default::default() };
std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle {
name: "Lead".into(),
based_on: None,
chars,
shortcut: String::new(),
});
compose_story(&d, sid, &ComposeOptions::default())
};
let mut checked = 0;
for w in (160..320).step_by(10) {
for h in (12..40).step_by(2) {
// Where, with the setting on, a hyphenated first line ends column 1.
let on = compose(w as f64, h as f64, true);
let lines = all_lines(&on);
if !(lines.iter().filter(|l| l.column == 0).count() == 1 && lines[0].hyphenated) {
continue;
}
let cs = compose(w as f64, h as f64, false);
let lines = all_lines(&cs);
assert!(!lines[0].hyphenated, "{w}×{h}");
let lead = |l: &Line| {
l.glyphs.iter().filter(|g| g.len > 0 && g.visible).map(|g| cs.styles[g.style as usize].fill == "[Registration]").collect::<Vec<_>>()
};
assert!(lead(lines[0]).iter().all(|&r| r), "{w}×{h}: first line");
assert!(lead(lines[1]).iter().all(|&r| !r), "{w}×{h}: second line");
checked += 1;
}
}
assert!(checked > 3, "{checked}");
}
#[test]
fn discretionary_hyphen_replaces_automatic_points() {
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
@@ -6611,7 +6697,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((12.0, 0.0));
tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { start: 0.0, increment: 12.0 });
}
edit(&mut d, sid);
// Vertical frames have no baseline grid.
@@ -7266,6 +7352,127 @@ fn cache_recomposes_when_the_baseline_grid_changes() {
assert!((la.baseline - lb.baseline).abs() > 1.0, "{} {}", la.baseline, lb.baseline);
}
/// Two single pages 618 pt tall, top margins 36 and 48, each with an overset 11/13 story aligned
/// to a baseline grid (start 0, increment 13) and a frame filling its margin box (bottom 582).
fn grid_margin_doc(relative_to: designcraft_doc::GridRelative) -> (Document, [StoryId; 2]) {
let mut d = Document::new(&NewDocument {
height: 618.0,
pages: 2,
facing_pages: false,
margins: designcraft_doc::Margins::uniform(36.0),
..Default::default()
});
Arc::make_mut(&mut d.spreads[1]).pages[0].margins.top = 48.0;
Arc::make_mut(&mut d.spreads[1]).pages[0].local_margins = true;
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 13.0;
d.settings.baseline_grid.relative_to = relative_to;
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let text = [LOREM; 12].join(" ");
let mut sids = Vec::new();
for (si, top) in [(0, 36.0), (1, 48.0)] {
let (_, sid) = d.add_text_frame(SpreadRef::Doc(si), Rect::new(36.0, top, 336.0, 582.0), lid, &text, para.clone()).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() };
sids.push(sid);
}
(d, [sids[0], sids[1]])
}
/// Page y of a frame's baselines (the frame's inner space is its page's: it has no transform).
fn page_baselines(d: &Document, sid: StoryId) -> Vec<f64> {
let cs = compose_story(d, sid, &ComposeOptions::default());
assert!(cs.is_overset());
cs.frames[0].lines.iter().map(|l| l.baseline).collect()
}
#[test]
fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() {
use designcraft_doc::GridRelative;
let (d, [p1, p2]) = grid_margin_doc(GridRelative::TopMargin);
// Grid lines at 36 + 13n on page 1: 42 lines from 49 to 582.
let lines = page_baselines(&d, p1);
assert!((lines[0] - 49.0).abs() < 1e-6, "{lines:?}");
assert!(lines.iter().enumerate().all(|(n, y)| (y - (49.0 + 13.0 * n as f64)).abs() < 1e-6), "{lines:?}");
assert_eq!(lines.len(), 42);
// Page 2's own margin: lines at 48 + 13n.
let lines = page_baselines(&d, p2);
assert!((lines[0] - 61.0).abs() < 1e-6, "{lines:?}");
// From the top of the page the lines are at 13n: the first at 52, and one line fewer.
let (d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage);
let lines = page_baselines(&d, p1);
assert!((lines[0] - 52.0).abs() < 1e-6, "{lines:?}");
assert_eq!(lines.len(), 41);
}
/// 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]
fn baseline_grid_follows_a_grouped_frame_to_its_page() {
use designcraft_doc::{Content, GridRelative, Item, ItemId, Shape};
let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() });
Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0;
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 13.0;
d.settings.baseline_grid.relative_to = GridRelative::TopMargin;
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(1), Rect::new(36.0, 36.0, 336.0, 582.0), lid, LOREM, para).unwrap();
let sp = Arc::make_mut(&mut d.spreads[1]);
let at = sp.items.iter().position(|it| it.id == fid).unwrap();
let frame = sp.items.remove(at);
let right = sp.pages[1].x;
let mut g = Item::new(ItemId(d.alloc()), lid, Shape::Group, designcraft_geom::PathData::default());
g.content = Content::Group { items: vec![frame] };
g.xf = designcraft_geom::Affine::translate((right, 0.0));
Arc::make_mut(&mut d.spreads[1]).items.push(Arc::new(g));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let first = cs.frames[0].lines[0].baseline;
assert!((first - 61.0).abs() < 1e-6, "{first}");
}
#[test]
fn cache_recomposes_when_the_grid_reference_or_a_top_margin_changes() {
use designcraft_doc::GridRelative;
let (mut d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage);
let cache = Cache::new();
let a = cache.get(&d, p1, None);
d.settings.baseline_grid.relative_to = GridRelative::TopMargin;
let b = cache.get(&d, p1, None);
assert!(!Arc::ptr_eq(&a, &b));
assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0);
Arc::make_mut(&mut d.spreads[0]).pages[0].margins.top = 40.0;
let c = cache.get(&d, p1, None);
assert!(!Arc::ptr_eq(&b, &c));
assert!((b.frames[0].lines[0].baseline - c.frames[0].lines[0].baseline).abs() > 1.0);
}
/// Pages trading places under a frame: the frame is on the other page, with its top margin.
#[test]
fn cache_recomposes_when_the_page_under_a_frame_changes() {
use designcraft_doc::GridRelative;
let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() });
Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0;
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 13.0;
d.settings.baseline_grid.relative_to = GridRelative::TopMargin;
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let x = d.spreads[1].pages[0].x;
let (_, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(x + 36.0, 36.0, x + 336.0, 582.0), lid, LOREM, para).unwrap();
let cache = Cache::new();
let a = cache.get(&d, sid, None);
let sp = Arc::make_mut(&mut d.spreads[1]);
let (x0, x1) = (sp.pages[0].x, sp.pages[1].x);
(sp.pages[0].x, sp.pages[1].x) = (x1, x0);
let b = cache.get(&d, sid, None);
assert!(!Arc::ptr_eq(&a, &b));
assert_eq!(b.frames[0].lines[0].baseline, compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline);
assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0);
}
#[test]
fn cache_recomposes_when_a_wrap_layer_is_hidden() {
let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default());
+3
View File
@@ -1270,6 +1270,9 @@ attr_set! {
number_expression: String = "^#.^t".into(),
/// Text after the bullet (metacharacters expand as in [`expand_list_text`]).
list_separator: String = "\t".into(),
/// The hanging indent was set by the Bulleted or Numbered List button, which takes it away
/// again with the list (an indent the paragraph had before stays).
list_hang: bool = false,
balance_ragged: bool = false,
optical_margin: bool = false,
/// None uses the legacy built-in rules; an empty set explicitly disables kinsoku.
+31 -1
View File
@@ -452,12 +452,34 @@ pub struct TextFrameOptions {
pub column_rule_bottom_inset: f64,
/// Use a custom baseline grid for this frame.
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_grid: Option<(f64, f64)>,
pub baseline_grid: Option<FrameBaselineGrid>,
/// Type on a Path: the text runs along the item's path instead of filling it.
#[serde(skip_serializing_if = "Option::is_none")]
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)")]
pub struct FrameBaselineGrid {
/// The first grid line, down from the top of the page.
pub start: f64,
/// The distance between grid lines; 0 or less turns the grid off.
pub increment: f64,
}
impl From<(f64, f64)> for FrameBaselineGrid {
fn from((start, increment): (f64, f64)) -> Self {
FrameBaselineGrid { start, increment }
}
}
impl From<FrameBaselineGrid> for (f64, f64) {
fn from(g: FrameBaselineGrid) -> Self {
(g.start, g.increment)
}
}
/// Type on a Path Options.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@@ -1662,6 +1684,14 @@ mod tests {
assert_eq!(*c, ClippingPath::new(ClippingKind::AlphaChannel, PathData::default()));
}
#[test]
fn frame_baseline_grid_saves_as_start_then_increment() {
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!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0]));
}
#[test]
fn bounds_and_text_area() {
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)));
+11
View File
@@ -147,6 +147,17 @@ impl Default for BaselineGrid {
}
}
impl BaselineGrid {
/// The first grid line on `page`, measured down from the page top: `start` from the top of
/// the page or from its top margin. The other lines follow every `increment` below it.
pub fn origin(&self, page: &page::Page) -> f64 {
match self.relative_to {
GridRelative::TopOfPage => self.start,
GridRelative::TopMargin => self.start + page.margins.top,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DocumentGrid {
+47 -2
View File
@@ -231,11 +231,24 @@ fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> {
Ok(nd)
}
/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a
/// paragraph's last word or across a column. Opened and imported files keep their own values,
/// which is why `ParaProps` keeps the IDML defaults (on).
fn new_document(nd: &NewDocument) -> Document {
let mut d = Document::new(nd);
if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) {
st.para.hyph_capitalized = Some(false);
st.para.hyph_last_word = Some(false);
st.para.hyph_across_column = Some(false);
}
d
}
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
let mut nd = new_document_settings(s, p)?;
s.untitled += 1;
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
let d = Document::new(&nd);
let d = new_document(&nd);
let i = s.add_document(DocState::new(d, None));
Ok(json!({"index": i}))
}
@@ -259,7 +272,7 @@ impl Session {
self.untitled += 1;
}
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title());
let st = DocState::new(Document::new(&nd), None);
let st = DocState::new(new_document(&nd), None);
let uid = st.uid;
match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
Some((i, d)) => {
@@ -2124,3 +2137,35 @@ mod recent_tests {
assert!(old.recent_files.is_empty(), "older prefs.json without the list still load");
}
}
#[cfg(test)]
mod hyphenation_default_tests {
use std::sync::Arc;
use designcraft_doc::BASIC_PARAGRAPH;
use designcraft_doc::build::NewDocument;
use serde_json::json;
use crate::Session;
fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) {
let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default());
(pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column)
}
#[test]
fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false));
let mut d = designcraft_doc::Document::new(&NewDocument::default());
let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap();
st.para.hyph_capitalized = Some(true);
st.para.hyph_last_word = Some(true);
st.para.hyph_across_column = Some(true);
let b64 = super::base64_encode(&designcraft_idml::export_idml(&d));
s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap();
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true));
}
}
+265 -2
View File
@@ -1,13 +1,37 @@
//! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from
//! story to story. Paragraphs join one with `listName` (and restart with `startAt`).
use designcraft_doc::NumberedList;
use designcraft_doc::{ListType, NumberedList};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, str_param};
use crate::{Result, Session};
/// The hanging indent a list gets from the Bulleted and Numbered List buttons, as InDesign's
/// default (Left Indent 0.25 in, First Line Indent −0.25 in): the bullet or number at the margin,
/// the text after its tab at the left indent.
pub const LIST_HANG: f64 = 18.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"list.bullets",
"Apply Bullets",
["Type", "Bulleted & Numbered Lists"],
None,
"{on?: bool (default: off when every selected paragraph has bullets, else on)} — the Bulleted List button: paragraphs with no indents get the default hanging indent (left 18 pt, first line −18 pt); taking bullets off removes that indent if it is still as applied",
has_text_or_frames,
|s, p| apply_list(s, p, ListType::Bullets)
),
cmd!(
"list.numbers",
"Apply Numbers",
["Type", "Bulleted & Numbered Lists"],
None,
"{on?: bool (default: off when every selected paragraph is numbered, else on)} — the Numbered List button, as list.bullets",
has_text_or_frames,
|s, p| apply_list(s, p, ListType::Numbers)
),
cmd!(
"list.define",
"Define Lists…",
@@ -48,12 +72,181 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Bullets or numbers on (or off) for the selected paragraphs, as InDesign's Bulleted and
/// Numbered List buttons: one undo step. Values equal to the paragraph style's are left to the
/// style rather than set locally, so a paragraph back to its style shows no override.
fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result<Value> {
let name = if kind == ListType::Bullets { "list.bullets" } else { "list.numbers" };
let on = match p.get("on") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(name, "`on` must be true or false"))?),
};
let targets = super::text::format_targets_pub(s);
if targets.is_empty() {
return Err(bad(name, "select text or a text frame"));
}
// Off when every selected paragraph already has this list, as the button shows it pressed.
let on = on.unwrap_or_else(|| !s.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind)));
s.edit(|d, _| {
let styles = d.styles.clone();
for t in &targets {
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
let span = para_span(&st.text, &t.range);
st.format_paras(span, |pf| {
let (now, _) = styles.resolve_para(pf);
// Off takes away this list only: a paragraph with the other list keeps it.
if !on && now.list_type != kind {
return;
}
let (style, _) = styles.resolve_para_style(&pf.style);
// The hanging indent this button set (and nothing has changed since).
let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG;
// A value: left to the style when it gives the same, else set here.
let mut set = |key: &str, v: Value, same: bool| {
let _ = pf.para.set_json(key, if same { &Value::Null } else { &v });
};
let list = if on { kind } else { ListType::None };
set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list);
if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 {
set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG);
set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG);
set("listHang", json!(true), false);
} else if !on && now.list_type == kind {
// Only the hanging indent set here: one the paragraph had before, or its style
// gives, stays.
if own_hang {
set("leftIndent", json!(0.0), style.left_indent == 0.0);
set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0);
}
set("listHang", Value::Null, true);
}
});
}
Ok(json!({"on": on}))
})
}
/// The range whose paragraphs a list applies to: a selection ending where a paragraph starts (after
/// a return) doesn't reach into that paragraph. The story's last paragraph, empty after a final
/// return, stays in when the range runs to the end (a whole frame).
fn para_span(text: &str, range: &std::ops::Range<usize>) -> std::ops::Range<usize> {
let end = range.end.max(range.start);
if end > range.start && end < text.len() && text.as_bytes().get(end - 1) == Some(&b'\n') { range.start..end - 1 } else { range.start..end }
}
/// Whether every paragraph of `targets` has list `kind`.
fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool {
targets.iter().all(|t| {
d.text_story(t.story, t.cell).is_some_and(|st| {
let span = para_span(&st.text, &t.range);
let (a, b) = (st.para_at(span.start), st.para_at(span.end));
st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind))
})
})
}
impl Session {
/// Whether the Bulleted or Numbered List button (and Type › Apply Bullets or Numbers) shows
/// pressed: every paragraph the list commands apply to has list `kind`, through the selected
/// text, table cells or text frames.
pub fn selection_has_list(&self, kind: ListType) -> bool {
let targets = super::text::format_targets_pub(self);
!targets.is_empty() && self.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind))
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
/// The Bulleted List button, as InDesign's: bullets with the default hanging indent (the
/// bullet at the margin, the text 18 pt in); again, bullets and that indent go, back to the
/// style. Numbers swap in keeping the indent; paragraphs with their own indents keep them.
#[test]
fn list_buttons_apply_the_default_hanging_indent() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 9})).unwrap();
let para = |s: &Session, i: usize| {
let d = &s.doc().unwrap().doc;
let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
(d.styles.resolve_para(&st.paras[i]).0, st.paras[i].para.clone())
};
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
for i in 0..2 {
let (p, _) = para(&s, i);
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::Bullets, 18.0, -18.0));
}
assert_eq!(para(&s, 2).0.list_type, designcraft_doc::ListType::None, "the paragraph outside the selection");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// The number of a bullet's tab: the text starts at the left indent.
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
let line = &cs.frames[0].lines[0];
let text_x = line.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).next().unwrap();
assert!((text_x - line.x0 - 18.0).abs() < 0.5, "the text at the hanging indent: {} from {}", text_x, line.x0);
// Numbers instead: the indent stays.
s.execute("list.numbers", &json!({})).unwrap();
let (p, _) = para(&s, 0);
assert_eq!((p.list_type, p.left_indent), (designcraft_doc::ListType::Numbers, 18.0));
// Numbers off: list and hanging indent go, and nothing is left set on the paragraph.
assert_eq!(s.execute("list.numbers", &json!({})).unwrap()["on"], false);
let (p, own) = para(&s, 0);
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 0.0, 0.0));
assert_eq!(own, designcraft_doc::ParaAttrs::default(), "back to the style");
// A paragraph with its own indents keeps them.
s.execute("type.para", &json!({"attrs": {"leftIndent": 36}})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
let (p, _) = para(&s, 0);
assert_eq!((p.left_indent, p.first_line_indent), (36.0, 0.0));
s.execute("list.bullets", &json!({"on": false})).unwrap();
assert_eq!(para(&s, 0).0.left_indent, 36.0);
assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err());
}
/// Pressed means every targeted paragraph has the list: a selection reaching an unbulleted
/// paragraph, or a frame with one, isn't. Explicitly off, a list button takes away its own
/// list only: a bulleted paragraph keeps bullets and indent when Numbers go off.
#[test]
fn a_list_is_pressed_and_turned_off_for_its_own_paragraphs() {
use designcraft_doc::ListType;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
let sid = f["story"].as_u64().unwrap();
let select = |s: &mut Session, a: usize, b: usize| {
s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap();
};
select(&mut s, 0, 3);
s.execute("list.bullets", &json!({})).unwrap();
assert!(s.selection_has_list(ListType::Bullets));
assert!(!s.selection_has_list(ListType::Numbers));
// From the bulleted Eggs into the plain Milk: not pressed, and a press bullets both.
select(&mut s, 0, 7);
assert!(!s.selection_has_list(ListType::Bullets));
// The frame selected: its whole story, Flour unbulleted.
s.execute("selection.set", &json!({"ids": [f["id"]]})).unwrap();
assert!(!s.selection_has_list(ListType::Bullets));
// Eggs bulleted, Milk numbered; Numbers off over both leaves Eggs' bullets and indent.
select(&mut s, 5, 7);
s.execute("list.numbers", &json!({"on": true})).unwrap();
select(&mut s, 0, 7);
s.execute("list.numbers", &json!({"on": false})).unwrap();
let d = &s.doc().unwrap().doc;
let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
let p = |i: usize| d.styles.resolve_para(&st.paras[i]).0;
assert_eq!((p(0).list_type, p(0).left_indent, p(0).first_line_indent), (ListType::Bullets, 18.0, -18.0));
assert_eq!((p(1).list_type, p(1).left_indent), (ListType::None, 0.0));
// With no text or frame selected, nothing is pressed.
s.execute("selection.set", &json!({"ids": []})).unwrap();
assert!(!s.selection_has_list(ListType::Bullets));
}
/// The digit the story's first line starts with (its list number).
fn digit(g: &designcraft_compose::PlacedGlyph) -> char {
let face = g.face.get();
@@ -99,4 +292,74 @@ mod tests {
s.execute("type.para", &json!({"attrs": {"startAt": null}})).unwrap();
assert_eq!(first_char(&s, stories[1]), '4');
}
/// A selection ending right after a return (the whole first paragraph, as a triple click selects
/// it) applies the list to that paragraph only, and the button shows it pressed.
#[test]
fn a_selection_ending_at_a_paragraph_start_leaves_that_paragraph_out() {
use designcraft_doc::ListType;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s
.execute(
"frame.create",
&json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs
Milk
Flour
"}),
)
.unwrap();
let sid = f["story"].as_u64().unwrap();
let kinds = |s: &Session| {
let d = &s.doc().unwrap().doc;
d.story(designcraft_doc::StoryId(sid)).unwrap().paras.iter().map(|p| d.styles.resolve_para(p).0.list_type).collect::<Vec<_>>()
};
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
assert!(!s.selection_has_list(ListType::Bullets));
assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
assert_eq!(kinds(&s), [ListType::Bullets, ListType::None, ListType::None, ListType::None]);
assert!(s.selection_has_list(ListType::Bullets), "pressed for the paragraph selected");
// The whole frame still takes in its last, empty paragraph.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 16})).unwrap();
s.execute("list.numbers", &json!({"on": true})).unwrap();
assert_eq!(kinds(&s), [ListType::Numbers; 4]);
}
/// A paragraph's own hanging indent, the same as the button's, stays when the list goes off:
/// the button takes away only the indent it set.
#[test]
fn a_paragraphs_own_hanging_indent_stays_when_the_list_goes_off() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.para", &json!({"attrs": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
s.execute("list.bullets", &json!({"on": false})).unwrap();
let d = &s.doc().unwrap().doc;
let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
let p = d.styles.resolve_para(pf).0;
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
assert_eq!(pf.para.list_hang, None, "nothing of the list left");
}
/// A hanging indent the paragraph style gives stays when the list goes off.
#[test]
fn a_style_hanging_indent_stays_when_the_list_goes_off() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("style.paragraph.create", &json!({"name": "Hang", "para": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Hang"})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
s.execute("list.bullets", &json!({"on": false})).unwrap();
let d = &s.doc().unwrap().doc;
let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
let p = d.styles.resolve_para(pf).0;
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
assert_eq!(pf.para, designcraft_doc::ParaAttrs::default(), "nothing left set on the paragraph");
}
}
+3 -3
View File
@@ -1944,12 +1944,12 @@ impl<'a> Ex<'a> {
}
if let Content::Text(t) = &it.content {
el.push(text_frame_pref(&t.options, &self.sw(&t.options.column_rule_color)));
if let Some((start, inc)) = t.options.baseline_grid {
if let Some(g) = t.options.baseline_grid {
el.push(
El::new("BaselineFrameGridOption")
.attr("UseCustomBaselineFrameGrid", "true")
.attr("StartingOffsetForBaselineFrameGrid", num(start))
.attr("BaselineFrameGridIncrement", num(inc)),
.attr("StartingOffsetForBaselineFrameGrid", num(g.start))
.attr("BaselineFrameGridIncrement", num(g.increment)),
);
}
}
+4 -2
View File
@@ -2974,8 +2974,10 @@ impl<'r> Importer<'r> {
if let Some(g) = self.with_style(e, "BaselineFrameGridOption")
&& g.get("UseCustomBaselineFrameGrid") == Some("true")
{
options.baseline_grid =
Some((g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), g.num("BaselineFrameGridIncrement").unwrap_or(12.0)));
options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid {
start: g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0),
increment: g.num("BaselineFrameGridIncrement").unwrap_or(12.0),
});
}
it.content = Content::Text(TextFrame { story, options });
self.ctx.threads.push((
+2
View File
@@ -10,6 +10,8 @@ use super::*;
mod assets;
mod clipping;
mod decorations;
mod facing_ltr;
mod frame_grid;
mod index_sort;
mod liquid_guides;
mod object_styles;
+111
View File
@@ -0,0 +1,111 @@
use designcraft_doc::PageSide::{self, Left, Right};
use super::*;
const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0""#;
/// A running head: a text frame across `x0..x1` (IDML spread x), 20 pt below the page top.
fn head(id: &str, story: &str, x0: f64, x1: f64) -> String {
let pts: String = [(x0, -280.0), (x0, -260.0), (x1, -260.0), (x1, -280.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<TextFrame Self="{id}" ParentStory="{story}" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"#
)
}
fn story(id: &str, text: &str) -> String {
format!(
r#"<idPkg:Story {NS}><Story Self="{id}"><ParagraphStyleRange><CharacterStyleRange><Content>{text}</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
)
}
/// Bound left to right with facing pages: the first page of a spread lies left of the spine
/// (except page 1, alone on the right). Every page has margins `Left=36 Right=120` (inside 36,
/// outside 120), the parent's left page a ruler guide at 108 and a "Left head" running head, its
/// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure
/// from each page.
fn left_to_right_fixture() -> Vec<u8> {
let margins = r#"<MarginPreference ColumnCount="1" ColumnGutter="12" Top="36" Bottom="36" Left="36" Right="120"/>"#;
let page = |id: &str, x: f64, applied: bool, guide: Option<f64>| {
let applied = if applied { r#" AppliedMaster="m1""# } else { "" };
let guide = guide.map_or(String::new(), |at| format!(r#"<Guide Self="{id}g" Orientation="Vertical" Location="{at}"/>"#));
format!(r#"<Page Self="{id}"{applied} GeometricBounds="0 0 600 400" ItemTransform="1 0 0 1 {x} -300">{margins}{guide}</Page>"#)
};
let master = format!(
r#"<idPkg:MasterSpread {NS}><MasterSpread Self="m1" Name="A-Parent" NamePrefix="A" BaseName="Parent" PageCount="2" ItemTransform="1 0 0 1 0 0">{}{}{}{}</MasterSpread></idPkg:MasterSpread>"#,
page("mp1", -400.0, false, Some(108.0)),
page("mp2", 0.0, false, Some(300.0)),
head("hl", "sl", -280.0, -36.0),
head("hr", "sr", 36.0, 280.0),
);
let spread = |id: &str, pages: &[(&str, f64)]| {
let ps: String = pages.iter().map(|(p, x)| page(p, *x, true, None)).collect();
format!(r#"<idPkg:Spread {NS}><Spread Self="{id}" PageCount="{}" ItemTransform="1 0 0 1 0 0">{ps}</Spread></idPkg:Spread>"#, pages.len())
};
let map = format!(
r#"<Document {NS} Self="d" StoryList="sl sr"><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1"/><idPkg:MasterSpread src="MasterSpreads/m1.xml"/><idPkg:Spread src="Spreads/sp0.xml"/><idPkg:Spread src="Spreads/sp1.xml"/><idPkg:Story src="Stories/sl.xml"/><idPkg:Story src="Stories/sr.xml"/></Document>"#
);
let prefs = format!(
r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="true" PageBinding="LeftToRight"/><ViewPreference RulerOrigin="PageOrigin"/></idPkg:Preferences>"#
);
zip_files(&[
("designmap.xml", &map),
("Resources/Preferences.xml", &prefs),
("MasterSpreads/m1.xml", &master),
("Spreads/sp0.xml", &spread("sp0", &[("p1", 0.0)])),
("Spreads/sp1.xml", &spread("sp1", &[("p2", -400.0), ("p3", 0.0)])),
("Stories/sl.xml", &story("sl", "Left head")),
("Stories/sr.xml", &story("sr", "Right head")),
])
}
/// The text of the running heads a document page shows from its parent page.
fn shown_heads(d: &Document, abs: usize) -> Vec<String> {
let (ppi, ppage) = d.parent_page_for(abs).unwrap();
let parent = &d.parents[ppi];
parent
.items
.iter()
.filter(|it| parent.page_at_x(it.bounds().center().x) == Some(ppage))
.filter_map(|it| d.story(it.text_frame()?.story).map(|s| s.text.clone()))
.collect()
}
/// Ruler guides of the parent page a document page shows, as x from that parent page's left edge.
fn parent_guides(d: &Document, abs: usize) -> Vec<f64> {
let (ppi, ppage) = d.parent_page_for(abs).unwrap();
let pp = &d.parents[ppi].pages[ppage];
pp.guides.iter().map(|g| g.position - pp.x).collect()
}
fn assert_right_pages_mirror_left_pages(d: &Document) {
assert!(d.settings.facing_pages && !d.settings.right_to_left_binding);
let sides: Vec<PageSide> = d.parents[0].pages.iter().map(|p| p.side).collect();
assert_eq!(sides, vec![Left, Right]);
// Right pages: inside (36) on the left; left pages: outside (120) on the left.
for (abs, side, live, guide, text) in [
(0, Right, (36.0, 280.0), 300.0, "Right head"),
(1, Left, (120.0, 364.0), 108.0, "Left head"),
(2, Right, (36.0, 280.0), 300.0, "Right head"),
] {
let n = abs + 1;
let page = d.page(abs).unwrap();
assert_eq!(page.side, side, "page {n}");
let m = page.margin_rect();
assert!((m.x0 - page.x - live.0).abs() < 1e-6 && (m.x1 - page.x - live.1).abs() < 1e-6, "page {n}: margins {m:?}");
assert_eq!(d.parents[0].pages[d.parent_page_for(abs).unwrap().1].side, side, "page {n} shows the parent's {side:?} page");
let guides = parent_guides(d, abs);
assert!(guides.len() == 1 && (guides[0] - guide).abs() < 1e-6, "page {n}: guides {guides:?}");
assert_eq!(shown_heads(d, abs), vec![text.to_string()], "page {n}");
}
}
#[test]
fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() {
let d = import_idml(&left_to_right_fixture()).unwrap();
assert_right_pages_mirror_left_pages(&d);
let again = import_idml(&export_idml(&d)).unwrap();
assert_right_pages_mirror_left_pages(&again);
}
+71
View File
@@ -0,0 +1,71 @@
use super::*;
const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="14.0""#;
/// A page with one text frame at its top edge whose custom baseline grid starts at 6 pt with a
/// 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 {
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>"#
);
let prefs = format!(r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="false"/></idPkg:Preferences>"#);
let pts: String = [(50.0, 0.0), (50.0, 300.0), (250.0, 300.0), (250.0, 0.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.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>
</Spread></idPkg:Spread>"#
);
let story = format!(
r#"<idPkg:Story {NS}><Story Self="s1"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" GridAlignment="AlignToBaseline"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>One</Content><Br/><Content>Two</Content><Br/><Content>Three</Content><Br/><Content>Four</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
);
let bytes = zip_files(&[
("designmap.xml", &designmap),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", &prefs),
("Spreads/Spread_sp1.xml", &spread),
("Stories/Story_s1.xml", &story),
]);
import_idml_with(&bytes, &|_| None).unwrap()
}
/// Each composed line's baseline below the frame top.
fn baselines(d: &Document) -> Vec<f64> {
let sid = *d.stories.keys().next().unwrap();
let top = d.spreads[0].items[0].text_area().y0;
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.baseline - top).collect()
}
#[test]
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 }));
let b = baselines(&d);
assert_eq!(b.len(), 4, "{b:?}");
for y in &b {
let n = (y - 6.0) / 14.0;
assert!((n - n.round()).abs() < 1e-6 && n >= 0.0, "baseline {y} is on the 6 + n·14 grid: {b:?}");
}
// The first line's ascent is past the 6 pt line, so it sits on the next one.
assert!((b[0] - 20.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn custom_frame_grid_round_trips() {
let d = frame_grid_doc();
let bytes = export_idml(&d);
let spread = zip_text(&bytes, "Spreads/");
assert!(spread.contains(r#"StartingOffsetForBaselineFrameGrid="6""#), "{spread}");
assert!(spread.contains(r#"BaselineFrameGridIncrement="14""#), "{spread}");
let back = import_idml(&bytes).unwrap();
let grid = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid;
assert_eq!(grid(&back), grid(&d));
assert_eq!(baselines(&back), baselines(&d));
}
+25 -5
View File
@@ -408,10 +408,11 @@ fn grid_origin(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis) -> Option<f6
/// Baseline grid. No overlay.
///
/// Lines are `page.y0 + start + n * increment` for n >= 0 while the line is inside the page
/// (the bottom edge is not a line, matching the canvas). An increment of 0 or less skips the
/// pass. `relative_to` and `view_threshold` are not read, and hiding the baseline grid does not
/// turn this off. Only a page the rect crosses on x contributes. A dragged ruler guide
/// Lines are `page.y0 + origin + n * increment` for n >= 0 while the line is inside the page
/// (the bottom edge is not a line, matching the canvas), `origin` from
/// [`BaselineGrid::origin`](designcraft_doc::BaselineGrid::origin). An increment of 0 or less
/// skips the pass. `view_threshold` is not read, and hiding the baseline grid does not turn this
/// off. Only a page the rect crosses on x contributes. A dragged ruler guide
/// ([`Gesture::Guide`]) doesn't snap to it.
fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides || matches!(req.gesture, Gesture::Guide) {
@@ -432,7 +433,10 @@ fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64)
if !ranges_overlap(moving_lo, moving_hi, lo, hi) {
continue;
}
let origin = bounds.y0 + baseline.start;
let origin = bounds.y0 + baseline.origin(page);
if !origin.is_finite() {
continue;
}
offer_enabled(&mut best, req.rect, flags, axis, tol, |v| nearest_baseline(v, origin, increment, bounds.y0, bounds.y1));
}
best.map(|b| b.hit)
@@ -1797,6 +1801,22 @@ mod tests {
assert_eq!(hit.delta.y, 0.0);
}
#[test]
fn baseline_relative_to_the_top_margin_snaps_from_the_margin() {
let mut doc = Document::new(&NewDocument::default());
std::sync::Arc::make_mut(&mut doc.spreads[0]).pages[0].margins.top = 37.0;
doc.settings.baseline_grid.start = 0.0;
doc.settings.baseline_grid.increment = 10.0;
doc.settings.baseline_grid.relative_to = designcraft_doc::GridRelative::TopMargin;
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let cx = ctx_on(&doc, &sel, &cache, &layout);
// Lines at 37 + 10n: a top at 58 goes up to 57, not down to the page-relative 60.
let hit = snap(&cx, y_req(Rect::new(100.0, 58.0, 140.0, 80.0)));
assert!((hit.delta.y - -1.0).abs() < 1e-6, "{}", hit.delta.y);
}
#[test]
fn nearer_of_margin_and_baseline_wins() {
// Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744.
+3 -2
View File
@@ -962,7 +962,8 @@ fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32,
const MAX_BASELINES: usize = 216 * 72;
/// Baseline grid lines inside a page, measured from its top: `start + n * increment` for n >= 0 (the bottom
/// edge is not a line). Lines above the page are skipped without walking them, however far up the start is.
/// edge is not a line), `start` from [`BaselineGrid::origin`](designcraft_doc::BaselineGrid::origin). Lines
/// above the page are skipped without walking them, however far up the start is.
fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator<Item = f64> {
let step = increment.max(1.0);
let first = if start < 0.0 { start.rem_euclid(step) } else { start };
@@ -1022,7 +1023,7 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document
// Baseline grid.
if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold {
let g = &s.baseline_grid;
for y in baseline_grid_lines(g.start, g.increment, p.height) {
for y in baseline_grid_lines(g.origin(p), g.increment, p.height) {
painter.line_segment(
[pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))],
Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)),
+666 -62
View File
@@ -7,7 +7,7 @@ use serde_json::json;
use crate::panels;
use crate::theme::{Tokens, semibold};
use crate::widgets::{caption, measure, number};
use crate::widgets::{NumField, caption, measure};
use crate::{DesignApp, icons};
/// Height of the application bar (InDesign 2026), then a 7 pt lower band and a 1 pt border. The
@@ -569,20 +569,101 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
/// The Control panel's font sizes (and leadings), as InDesign lists them.
const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0, 36.0, 48.0, 60.0, 72.0];
/// The Control panel's tracking values (1/1000 em).
const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
/// Whether the text Control panel shows Paragraph Formatting Controls (¶) rather than Character
/// Formatting Controls (A).
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> {
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);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip));
let label = resp.id;
resp.on_hover_text(tip);
field.spinner().labelled_by(label).show(ui)
}
/// A column of two rows (the Control panel's two-row groups).
fn rows(ui: &mut egui::Ui, mut row: impl FnMut(&mut egui::Ui, usize)) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 3.0;
ui.horizontal(|ui| row(ui, 0));
ui.horizontal(|ui| row(ui, 1));
});
}
/// A small toggle button showing `text` (the case and position buttons).
fn text_toggle(ui: &mut egui::Ui, text: &str, strike: bool, on: bool, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 20.0), Sense::click());
if on {
ui.painter().rect_filled(r, 2.0, t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
let col = if on { t.text_strong } else { t.icon };
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col);
if strike {
ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col));
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip));
resp.on_hover_text(tip).clicked()
}
/// The text Control panel, as InDesign's: A shows the Character Formatting Controls (font, size
/// and leading, the case and position buttons, kerning and tracking, the scales, baseline shift
/// and skew, the character style and language); ¶ the Paragraph Formatting Controls (alignment,
/// the indents, space before and after, the drop cap, the paragraph style, Hyphenate and Align to
/// Baseline Grid).
fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
let attrs = panels::text_attrs(app);
let chars = attrs.as_ref().map(|a| a["chars"].clone()).unwrap_or_default();
let para = attrs.as_ref().map(|a| a["para"].clone()).unwrap_or_default();
let mode_id = egui::Id::new(PARA_MODE);
let para_mode = ui.data(|d| d.get_temp::<bool>(mode_id)).unwrap_or(false);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("A").font(semibold(15.0)).color(t.text_strong));
});
ui.horizontal(|ui| {
ui.label(egui::RichText::new("").font(semibold(15.0)).color(t.text_dim));
});
ui.spacing_mut().item_spacing.y = 3.0;
for (para, text, tip) in [(false, "A", "Character Formatting Controls"), (true, "", "Paragraph Formatting Controls")] {
let on = para_mode == para;
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click());
if on {
ui.painter().rect_filled(r, 2.0, t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, semibold(15.0), if on { t.text_strong } else { t.text_dim });
let tip = tr(tip);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip));
if resp.on_hover_text(&tip).clicked() {
ui.data_mut(|d| d.insert_temp(mode_id, para));
}
}
});
vsep(ui);
if para_mode {
control_paragraph(app, ui, &attrs, &para, &tr);
} else {
control_character(app, ui, &attrs, &chars, &para, &tr);
}
}
fn control_character(
app: &mut DesignApp,
ui: &mut egui::Ui,
attrs: &Option<serde_json::Value>,
chars: &serde_json::Value,
para: &serde_json::Value,
tr: &dyn Fn(&str) -> String,
) {
ui.vertical(|ui| {
let fam = chars["fontFamily"].as_str().unwrap_or("").to_string();
let sty = chars["fontStyle"].as_str().unwrap_or("").to_string();
@@ -590,84 +671,358 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) {
panels::font_style_picker(app, ui, &fam, &sty, 170.0);
});
vsep(ui);
egui::Grid::new("ctl_char").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| {
caption(ui, "𝐓");
if let Some(v) = number(ui, "csize", chars["size"].as_f64(), " pt", 54.0, 2) {
let _ = app.run("type.char", json!({"attrs": {"size": v}}));
// Size and leading: a spinner on the left and a list of common values on the right;
// automatic leading in parentheses.
rows(ui, |ui, row| {
if row == 0 {
if let Some(v) = icon_field(
ui,
"font-size",
&tr("Font Size"),
NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).presets(SIZES).range(0.1, 1296.0),
) {
let _ = app.run("type.char", json!({"attrs": {"size": v}}));
}
} else {
let auto = chars["leading"]["kind"].as_str() != Some("points");
let lead = if auto {
chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2))
} else {
chars["leading"]["value"].as_f64()
};
let lead = lead.map(|v| (v * 100.0).round() / 100.0);
let key = if auto { "cleadauto" } else { "clead" };
if let Some(v) = icon_field(
ui,
"leading",
&tr("Leading"),
NumField::number(key, lead, " pt", 2).width(84.0).presets(SIZES).range(0.0, 5000.0).parens(auto),
) {
let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
}
}
caption(ui, crate::i18n::tr(&app.ui.language, "VA"));
if let Some(v) = number(ui, "ctrack", chars["tracking"].as_f64(), "", 48.0, 0) {
let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
});
vsep(ui);
// All Caps, Superscript, Underline / Small Caps, Subscript, Strikethrough.
let caps = chars["capitalization"].as_str().unwrap_or("normal").to_string();
let pos = chars["position"].as_str().unwrap_or("normal").to_string();
let (ul, st) = (chars["underline"].as_bool().unwrap_or(false), chars["strikethrough"].as_bool().unwrap_or(false));
let mut set = None;
rows(ui, |ui, row| {
if row == 0 {
if text_toggle(ui, "TT", false, caps == "allCaps", &tr("All Caps")) {
set = Some(json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }}));
}
if text_toggle(ui, "T¹", false, pos == "superscript", &tr("Superscript")) {
set = Some(json!({"position": if pos == "superscript" { "normal" } else { "superscript" }}));
}
if text_toggle(ui, "T̲", false, ul, &tr("Underline")) {
set = Some(json!({"underline": !ul}));
}
} else {
if text_toggle(ui, "Tᴛ", false, caps == "smallCaps", &tr("Small Caps")) {
set = Some(json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }}));
}
if text_toggle(ui, "T₁", false, pos == "subscript", &tr("Subscript")) {
set = Some(json!({"position": if pos == "subscript" { "normal" } else { "subscript" }}));
}
if text_toggle(ui, "T", true, st, &tr("Strikethrough")) {
set = Some(json!({"strikethrough": !st}));
}
}
ui.end_row();
caption(ui, "Ā");
let lead = match chars["leading"]["kind"].as_str() {
Some("points") => chars["leading"]["value"].as_f64(),
_ => chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)),
};
if let Some(v) = number(ui, "clead", lead, " pt", 54.0, 2) {
let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
});
if let Some(a) = set {
let _ = app.run("type.char", json!({"attrs": a}));
}
vsep(ui);
rows(ui, |ui, row| {
if row == 0 {
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, "kerning", t.icon);
resp.on_hover_text(tr("Kerning"));
crate::panels::properties::kerning_control(app, ui, "ckern", &chars["kerning"], 92.0);
} else {
if let Some(v) = icon_field(
ui,
"tracking",
&tr("Tracking"),
NumField::number("ctrack", chars["tracking"].as_f64(), "", 0).width(92.0).presets(TRACKING).step(5.0).range(-1000.0, 10000.0),
) {
let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
}
}
caption(ui, crate::i18n::tr(&app.ui.language, "IT"));
if let Some(v) = number(ui, "chs", chars["hScale"].as_f64().map(|v| v * 100.0), "%", 48.0, 0) {
let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}}));
});
vsep(ui);
const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0];
let pct = |v: &serde_json::Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0);
rows(ui, |ui, row| {
if row == 0 {
if let Some(v) = icon_field(
ui,
"vscale",
&tr("Vertical Scale"),
NumField::number("cvs", pct(&chars["vScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0),
) {
let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}}));
}
} else {
if let Some(v) = icon_field(
ui,
"hscale",
&tr("Horizontal Scale"),
NumField::number("chs", pct(&chars["hScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0),
) {
let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}}));
}
}
});
vsep(ui);
rows(ui, |ui, row| {
if row == 0 {
if let Some(v) = icon_field(
ui,
"baseline-shift",
&tr("Baseline Shift"),
NumField::number("cbs", chars["baselineShift"].as_f64(), " pt", 2).width(76.0).range(-5000.0, 5000.0),
) {
let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}}));
}
} else {
if let Some(v) = icon_field(
ui,
"skew",
&tr("Skew (False Italic)"),
NumField::number("csk", chars["skew"].as_f64(), "°", 1).width(76.0).range(-85.0, 85.0),
) {
let _ = app.run("type.char", json!({"attrs": {"skew": v}}));
}
}
ui.end_row();
});
vsep(ui);
ui.vertical(|ui| {
let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default();
ui.horizontal(|ui| {
for (a, icon) in [(Align::Left, "align-left"), (Align::Center, "align-center"), (Align::Right, "align-right")] {
if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() {
let _ = app.run("type.para", json!({"attrs": {"align": a}}));
ui.spacing_mut().item_spacing.y = 3.0;
// The character style, then the language.
let cs = attrs.as_ref().and_then(|a| a["characterStyle"].as_str()).unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string();
let names: Vec<String> = app.session.active().map(|d| d.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default();
let mut pick = None;
egui::ComboBox::from_id_salt("ctl_char_style")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &cs)))
.width(150.0)
.show_ui(ui, |ui| {
for n in &names {
if ui.selectable_label(*n == cs, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
pick = Some(n.clone());
}
}
}
});
ui.horizontal(|ui| {
for (a, icon) in [(Align::LeftJustified, "align-justify"), (Align::FullyJustified, "align-justify-all")] {
if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() {
let _ = app.run("type.para", json!({"attrs": {"align": a}}));
}
}
});
if let Some(n) = pick {
let _ = app.run("style.character.apply", json!({"name": n}));
}
let lang = chars["language"].as_str().unwrap_or("English: USA").to_string();
egui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| crate::panels::properties::language_menu(app, ui, &lang));
});
});
}
fn control_paragraph(
app: &mut DesignApp,
ui: &mut egui::Ui,
attrs: &Option<serde_json::Value>,
para: &serde_json::Value,
tr: &dyn Fn(&str) -> String,
) {
let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default();
let mut align = None;
rows(ui, |ui, row| {
if row == 0 {
for (a, icon) in [
(Align::Left, "palign-left"),
(Align::Center, "palign-center"),
(Align::Right, "palign-right"),
(Align::TowardsSpine, "palign-spine-towards"),
] {
if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() {
align = Some(a);
}
}
} else {
for (a, icon) in [
(Align::LeftJustified, "palign-justify-left"),
(Align::CenterJustified, "palign-justify-center"),
(Align::RightJustified, "palign-justify-right"),
(Align::FullyJustified, "palign-justify-all"),
(Align::AwayFromSpine, "palign-spine-away"),
] {
if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() {
align = Some(a);
}
}
}
});
if let Some(a) = align {
let _ = app.run("type.para", json!({"attrs": {"align": a}}));
}
vsep(ui);
let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
egui::Grid::new("ctl_para").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| {
caption(ui, "→|");
if let Some(v) = measure(ui, "cli", para["leftIndent"].as_f64(), units, 54.0) {
let _ = app.run("type.para", json!({"attrs": {"leftIndent": v}}));
let mut set = None;
for ((ia, ta, ka), (ib, tb, kb)) in [
(("indent-left", "Left Indent", "leftIndent"), ("indent-first", "First Line Left Indent", "firstLineIndent")),
(("indent-right", "Right Indent", "rightIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")),
(("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")),
] {
rows(ui, |ui, row| {
if row == 0 {
if let Some(v) = icon_field(ui, ia, &tr(ta), NumField::measure(ka, para[ka].as_f64(), units).width(84.0).range(0.0, 10_000.0)) {
set = Some((ka, v));
}
} else {
let min = crate::panels::properties::para_field_min(kb);
if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) {
set = Some((kb, v));
}
}
});
vsep(ui);
}
if let Some((k, v)) = set {
let _ = app.run("type.para", json!({"attrs": {k: v}}));
}
let mut drop = None;
rows(ui, |ui, row| {
if row == 0 {
let max = designcraft_compose::MAX_DROP_CAP_LINES as f64;
if let Some(v) = icon_field(
ui,
"drop-lines",
&tr("Drop Cap Number of Lines"),
NumField::number("cdl", para["dropCapLines"].as_f64(), "", 0).width(64.0).range(0.0, max),
) {
drop = Some(("dropCapLines", v));
}
} else {
let max = designcraft_compose::MAX_DROP_CAP_CHARS as f64;
if let Some(v) = icon_field(
ui,
"drop-chars",
&tr("Drop Cap One or More Characters"),
NumField::number("cdc", para["dropCapChars"].as_f64(), "", 0).width(64.0).range(0.0, max),
) {
drop = Some(("dropCapChars", v));
}
}
caption(ui, "↑¶");
if let Some(v) = measure(ui, "csb", para["spaceBefore"].as_f64(), units, 54.0) {
let _ = app.run("type.para", json!({"attrs": {"spaceBefore": v}}));
}
ui.end_row();
caption(ui, "→¶");
if let Some(v) = measure(ui, "cfi", para["firstLineIndent"].as_f64(), units, 54.0) {
let _ = app.run("type.para", json!({"attrs": {"firstLineIndent": v}}));
}
caption(ui, "↓¶");
if let Some(v) = measure(ui, "csa", para["spaceAfter"].as_f64(), units, 54.0) {
let _ = app.run("type.para", json!({"attrs": {"spaceAfter": v}}));
}
ui.end_row();
});
if let Some((k, v)) = drop {
let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}}));
}
vsep(ui);
// Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or,
// pressed, none). Pressed when every selected paragraph has it, as Type › Apply Bullets shows.
use designcraft_doc::ListType;
let pressed = [ListType::Bullets, ListType::Numbers].map(|k| app.session.selection_has_list(k));
let mut list = None;
rows(ui, |ui, row| {
let (icon, cmd, tip) = if row == 0 { ("bullets", "list.bullets", "Bulleted List") } else { ("numbering", "list.numbers", "Numbered List") };
let on = pressed.get(row).copied().unwrap_or(false);
if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() {
list = Some((cmd, !on));
}
});
if let Some((cmd, on)) = list {
let _ = app.run(cmd, json!({"on": on}));
}
vsep(ui);
// Shading over Border: on or off, the Paragraph Borders and Shading dialog on its tab, and the
// colour, as InDesign's.
let doc = app.session.active().map(|d| d.doc.clone());
let mut shade = None;
let mut dialog = None;
// Both checkboxes as wide as the wider label, so the icons and colours line up in columns.
let check_w = ["Shading", "Border"]
.iter()
.map(|l| ui.fonts_mut(|f| f.layout_no_wrap(tr(l), egui::FontId::proportional(11.5), Color32::WHITE).size().x))
.fold(0.0, f32::max)
+ 28.0;
rows(ui, |ui, row| {
let (on_key, color_key, label, icon, tab) = if row == 0 {
("shadingOn", "shadingColor", "Shading", "para-shading", "shading")
} else {
("borderOn", "borderColor", "Border", "para-border", "border")
};
let mut on = para[on_key].as_bool().unwrap_or(false);
ui.allocate_ui_with_layout(vec2(check_w, crate::widgets::FIELD_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(check_w);
if crate::widgets::checkbox(ui, &mut on, &tr(label)).changed() {
shade = Some(json!({ on_key: on }));
}
});
if crate::widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…")).clicked() {
dialog = Some(tab);
}
if let Some(doc) = &doc {
let cur = para[color_key].as_str().unwrap_or("[Black]").to_string();
if let Some(w) = swatch_field(ui, doc, &cur, 96.0, &tr(if row == 0 { "Paragraph Shading" } else { "Paragraph Border" })) {
shade = Some(json!({ color_key: w }));
}
}
});
if let Some(attrs) = shade {
let _ = app.run("type.para", json!({ "attrs": attrs }));
}
if let Some(tab) = dialog {
let _ = app.run("app.paragraphBordersDialog", json!({ "tab": tab }));
}
vsep(ui);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 3.0;
let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string();
let ov = attrs.as_ref().and_then(|a| a["paraOverrides"].as_u64()).unwrap_or(0)
+ attrs.as_ref().and_then(|a| a["charOverrides"].as_u64()).unwrap_or(0);
panels::para_style_picker(app, ui, &ps, ov > 0, 170.0);
let hy = para["hyphenate"].as_bool().unwrap_or(true);
let mut h = hy;
if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() {
let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
}
ui.horizontal(|ui| {
let mut h = para["hyphenate"].as_bool().unwrap_or(true);
if crate::widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() {
let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
}
// First Line Only is aligned too: the button shows it pressed, and a click turns alignment off.
let grid = matches!(para["gridAlign"].as_str(), Some("allLines" | "firstLineOnly"));
if crate::widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() {
let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}}));
}
});
});
}
/// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name,
/// and the document's swatches in its menu. Returns the swatch picked.
pub(crate) fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option<String> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::click());
let resp = resp.on_hover_text(label);
resp.widget_info(|| {
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label);
info.current_text_value = Some(cur.to_string());
info
});
let p = ui.painter();
p.rect_filled(r, 0.0, if resp.hovered() { t.hover } else { t.input });
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
let seg = egui::Rect::from_min_max(egui::pos2(r.max.x - crate::widgets::SEG_W, r.min.y), r.max);
p.line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border));
icons::paint(p, egui::Rect::from_center_size(seg.center(), vec2(12.0, 12.0)), "chevron-down", t.icon);
let (c, g) = crate::widgets::swatch_colors(doc, cur, 1.0);
let chip = egui::Rect::from_min_size(egui::pos2(r.min.x + 4.0, r.center().y - 6.0), vec2(12.0, 12.0));
crate::widgets::paint_chip(p, chip, c, g);
let clip = egui::Rect::from_min_max(r.min, egui::pos2(seg.min.x - 2.0, r.max.y));
p.with_clip_rect(clip).text(egui::pos2(chip.max.x + 5.0, r.center().y), egui::Align2::LEFT_CENTER, cur, egui::FontId::proportional(11.5), t.text);
let mut picked = None;
egui::Popup::menu(&resp).show(|ui| {
picked = crate::panels::swatch_list(ui, doc, cur);
});
picked
}
/// The widest a document tab grows: a longer name is shortened so its close button and the
/// tabs after it stay in view.
const DOC_TAB_MAX_W: f32 = 260.0;
@@ -1119,6 +1474,28 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
#[cfg(test)]
mod tests {
/// A Control panel field is labelled by its icon, so assistive technology reads "Tracking"
/// (or "Font Size", …) with the value.
#[test]
fn icon_fields_are_labelled_by_their_icon() {
use egui::accesskit::Role;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, "en");
ctx.enable_accesskit();
let mut update = None;
for _ in 0..2 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
icon_field(ui, "tracking", "Tracking", NumField::number("tracking_test", Some(0.0), "", 0));
});
out.textures_delta.clear();
update = out.platform_output.accesskit_update;
}
let update = update.expect("an AccessKit tree");
let named = |n: &egui::accesskit::Node| n.label() == Some("Tracking") || n.value() == Some("Tracking");
let icon = update.nodes.iter().find(|(_, n)| named(n)).map(|(id, _)| *id).expect("the icon's node");
assert!(update.nodes.iter().any(|(_, n)| n.role() == Role::TextInput && n.labelled_by().contains(&icon)), "the editor names its icon");
}
use egui::{pos2, vec2};
use egui_kittest::kittest::{NodeT, Queryable};
@@ -1323,6 +1700,233 @@ mod tests {
assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right");
}
/// Zero2Graphic's page layout video (0:48): with the Type tool, the Control panel's font size
/// has a list of sizes; the instructor picks 72 pt from it. Leading has the same list.
#[test]
fn the_type_control_panel_lists_font_sizes_and_leadings() {
let mut app = app();
app.run("file.new", serde_json::json!({})).unwrap();
let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Headline"})).unwrap();
app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap();
app.session.set_tool("type");
let mut h = test_window::open(app, vec2(1800.0, 700.0));
let bar = test_window::panel_rect(&h, "control_bar");
let in_bar = |r: &egui::Rect| bar.y_range().contains(r.center().y);
let field = |h: &egui_kittest::Harness<'static, test_window::Window>, value: &str| {
h.query_all_by_value(value).map(|n| n.rect()).find(in_bar).unwrap_or_else(|| panic!("no {value:?} field in the Control panel"))
};
let press = |h: &mut egui_kittest::Harness<'static, test_window::Window>, p: egui::Pos2| {
h.hover_at(p);
h.drag_at(p);
h.drop_at(p);
};
let size_of = |h: &egui_kittest::Harness<'static, test_window::Window>| {
let st = h.state().app.session.doc().unwrap();
let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
st.doc.story(sid).unwrap().format_after(0).over.size
};
// The ⌄ segment right of the size field opens the list; choosing 72 pt sets the size.
let size = field(&h, "12 pt");
let chevron = pos2(size.max.x + 1.0 + crate::widgets::SEG_W / 2.0, size.center().y);
press(&mut h, chevron);
h.run_steps(3);
h.get_by_label("72 pt").click();
h.run_steps(3);
assert_eq!(size_of(&h), Some(72.0));
// Auto leading shows in parentheses, with the same list beside it.
let lead = field(&h, "(86.4 pt)");
press(&mut h, pos2(lead.max.x + 1.0 + crate::widgets::SEG_W / 2.0, lead.center().y));
h.run_steps(3);
h.get_by_label("60 pt").click();
h.run_steps(3);
let st = h.state().app.session.doc().unwrap();
let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(60.0)));
// The spinner left of the size steps it by a point.
let size = field(&h, "72 pt");
press(&mut h, pos2(size.min.x - 5.0 - crate::widgets::SEG_W / 2.0, size.min.y + 3.0));
h.run_steps(3);
assert_eq!(size_of(&h), Some(73.0));
// Typing the automatic value itself over "(87.6 pt)" fixes the leading at it.
h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap();
h.run_steps(3);
let lead = field(&h, "(87.6 pt)");
press(&mut h, lead.center());
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("87.6".into()));
h.key_press(egui::Key::Enter);
h.run_steps(3);
let st = h.state().app.session.doc().unwrap();
let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(87.6)));
// Escape cancels: the same typing, then Escape, leaves the leading automatic.
h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap();
h.run_steps(3);
let auto = h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading;
let lead = field(&h, "(87.6 pt)");
press(&mut h, lead.center());
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("87.6".into()));
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "Escape leaves it automatic");
// Escape also reverts a different number, in the leading field and in a plain one. (It
// leaves the text too, so each try selects it again.)
for (shown, typed) in [("(87.6 pt)", "20"), ("73 pt", "50")] {
h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap();
h.state_mut().app.session.set_tool("type");
h.run_steps(3);
let r = field(&h, shown);
press(&mut h, r.center());
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(typed.into()));
h.key_press(egui::Key::Escape);
h.run_steps(3);
}
assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "still automatic");
assert_eq!(size_of(&h), Some(73.0), "the size is unchanged");
}
/// The Control panel's ¶ mode on a First Line Only paragraph: Align to Baseline Grid shows
/// pressed, and a click turns alignment off instead of making it All Lines.
#[test]
fn the_control_panel_grid_button_keeps_first_line_only() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut app = app();
app.run("file.new", serde_json::json!({})).unwrap();
let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Bulbs"})).unwrap();
app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap();
app.run("type.para", serde_json::json!({"attrs": {"gridAlign": "firstLineOnly"}})).unwrap();
app.session.set_tool("type");
let mut h = test_window::open(app, vec2(2400.0, 700.0));
let bar = test_window::panel_rect(&h, "control_bar");
let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y);
let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str| {
let r = h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_bar(*r)).unwrap_or_else(|| panic!("no {label:?} in the Control panel"));
h.hover_at(r.center());
h.drag_at(r.center());
h.drop_at(r.center());
h.run_steps(3);
};
click(&mut h, "Paragraph Formatting Controls");
let pressed = h.query_all_by_label("Align to Baseline Grid").find(|n| in_bar(n.rect())).map(|n| n.accesskit_node().toggled());
assert_eq!(pressed, Some(Some(egui::accesskit::Toggled::True)));
assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["gridAlign"], "firstLineOnly");
click(&mut h, "Align to Baseline Grid");
assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["gridAlign"], "none");
}
/// The course's chapter 16: the text Control panel has InDesign's two modes. A shows the
/// Character Formatting Controls (the case buttons, kerning and tracking, the scales…), ¶ the
/// Paragraph Formatting Controls (alignment, indents, spacing, the drop cap…).
#[test]
fn the_type_control_panel_has_character_and_paragraph_modes() {
let mut app = app();
app.run("file.new", serde_json::json!({})).unwrap();
let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Bulbs"})).unwrap();
app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap();
app.session.set_tool("type");
let mut h = test_window::open(app, vec2(2400.0, 700.0));
let bar = test_window::panel_rect(&h, "control_bar");
let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y);
let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str| {
let r = h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_bar(*r)).unwrap_or_else(|| panic!("no {label:?} in the Control panel"));
h.hover_at(r.center());
h.drag_at(r.center());
h.drop_at(r.center());
h.run_steps(3);
};
let has = |h: &egui_kittest::Harness<'static, test_window::Window>, label: &str| h.query_all_by_label(label).any(|n| in_bar(n.rect()));
assert!(has(&h, "Small Caps") && has(&h, "Vertical Scale") && !has(&h, "Drop Cap Number of Lines"));
click(&mut h, "Small Caps");
click(&mut h, "Paragraph Formatting Controls");
assert!(has(&h, "Drop Cap Number of Lines") && has(&h, "Space Before") && !has(&h, "Small Caps"));
click(&mut h, "Align to Baseline Grid");
click(&mut h, "Align center");
let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap();
assert_eq!(a["chars"]["capitalization"], "smallCaps");
assert_eq!(a["para"]["gridAlign"], "allLines");
assert_eq!(a["para"]["align"], "center");
click(&mut h, "Character Formatting Controls");
assert!(has(&h, "Small Caps"));
}
/// The course's chapter 21: the ¶ Control panel has InDesign's Bulleted and Numbered List
/// buttons (with the default hanging indent), and Shading and Border with their colours and
/// the Paragraph Borders and Shading dialog.
#[test]
fn the_paragraph_control_panel_has_lists_shading_and_border() {
let mut app = app();
app.run("file.new", serde_json::json!({})).unwrap();
let f = app
.run(
"frame.create",
serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "4 eggs
1 cup milk"}),
)
.unwrap();
app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap();
app.session.set_tool("type");
let mut h = test_window::open(app, vec2(2400.0, 700.0));
let bar = test_window::panel_rect(&h, "control_bar");
let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y);
let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str, nth: usize| {
let r = h
.query_all_by_label(label)
.map(|n| n.rect())
.filter(|r| in_bar(*r))
.nth(nth)
.unwrap_or_else(|| panic!("no {label:?} in the Control panel"));
h.hover_at(r.center());
h.drag_at(r.center());
h.drop_at(r.center());
h.run_steps(3);
};
click(&mut h, "Paragraph Formatting Controls", 0);
click(&mut h, "Bulleted List", 0);
let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap();
assert_eq!(
(a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()),
(Some("bullets"), Some(18.0), Some(-18.0))
);
click(&mut h, "Numbered List", 0);
assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["listType"], "numbers");
click(&mut h, "Shading", 0);
click(&mut h, "Border", 0);
let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap();
assert_eq!((a["para"]["shadingOn"].as_bool(), a["para"]["borderOn"].as_bool()), (Some(true), Some(true)));
assert!(h.query_all_by_label("Paragraph Shading").any(|n| in_bar(n.rect())), "the shading colour");
// The icon beside Border opens the dialog on its Border tab.
click(&mut h, "Paragraph Borders and Shading…", 1);
let d = h.state().app.ui.dialog.clone().expect("the dialog");
assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "border"));
// The Last Line Right Indent takes a negative value, as the First Line Left Indent does.
h.state_mut().app.ui.dialog = None;
h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap();
h.state_mut().app.session.set_tool("type");
h.run_steps(3);
// The field is labelled by its icon.
let at = h
.query_all_by_label("Last Line Right Indent")
.filter(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput)
.map(|n| n.rect())
.find(|r| in_bar(*r))
.expect("the last line indent")
.center();
h.hover_at(at);
h.drag_at(at);
h.drop_at(at);
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("-0p6".into()));
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["lastLineIndent"].as_f64(), Some(-6.0));
}
#[test]
fn the_page_tool_has_its_own_control_panel() {
// Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the
+590 -285
View File
@@ -2064,10 +2064,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
match d.s("section").as_str() {
"span" => "Span Columns",
"bullets" => "Bullets and Numbering",
"keep" => "Keep Options",
"nested" => "Drop Caps and Nested Styles",
"grep" => "GREP Styles",
_ => "Paragraph Borders and Shading",
},
),
"justification" => crate::i18n::tr(&app.ui.language, "Justification"),
"hyphenation" => crate::i18n::tr(&app.ui.language, "Hyphenation Settings"),
"insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"),
"findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"),
"polygonSettings" => crate::i18n::tr(&app.ui.language, "Polygon Settings"),
@@ -2109,6 +2113,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"alert" => 440.0,
"closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0,
"newDocument" => 960.0,
"hyphenation" => 560.0,
"numberingSection" => 560.0,
crate::effects_dialog::ID => 720.0,
_ => 640.0,
@@ -2361,6 +2366,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
let current = d.fields.get("current").cloned().unwrap_or_default();
justification(&app.ui.language, ui, &mut d, "", &current);
}
"hyphenation" => {
let current = d.fields.get("current").cloned().unwrap_or_default();
let units = app.session.active().map_or(Unit::Picas, |st| st.doc.settings.horizontal_units);
let preview = crate::i18n::tr(&app.ui.language, "Preview").to_string();
ui.horizontal_top(|ui| {
ui.vertical(|ui| hyphenation(&app.ui.language, ui, &mut d, "", &current, units));
ui.add_space(24.0);
check(ui, &mut d, "preview", &preview);
});
hyphenation_preview(app, &mut d);
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(st).color(ui.visuals().error_fg_color));
}
}
"insertXref" => insert_xref(app, ui, &mut d),
"findFont" => find_font(app, ui, &mut d),
"colorPicker" => color_picker(ui, &mut d),
@@ -3173,6 +3193,24 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
app.run("type.para", json!({"attrs": attrs}))
}
"hyphenation" => {
// The preview goes; the edits are made once, as one undo step.
if d.fields.contains_key(PREVIEWED) {
app.session.cancel_interaction();
}
let attrs = hyphenation_edits(&d);
if attrs.is_empty() {
return Ok(Value::Null);
}
app.run("type.para", json!({"attrs": attrs})).inspect_err(|e| {
// The dialog stays open, with the reason, to be corrected.
app.status(e.clone());
let mut d = d;
d.fields.remove(PREVIEWED);
d.fields.insert("status".into(), json!(e));
app.ui.dialog = Some(d);
})
}
"justification" => {
let current = d.fields.get("current").cloned().unwrap_or_default();
if let Err(e) = check_justification(&d, "", &current) {
@@ -3911,270 +3949,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
text_field(ui, d, "x.class", 160.0);
});
}
"nested" => {
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Drop Caps")).font(semibold(12.0)));
egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
for (label, key, max) in [
("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES),
("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS),
] {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64();
if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) {
d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64)));
}
ui.end_row();
}
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Character Style:"));
let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string();
let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style };
egui::ComboBox::from_id_salt("ps_dropcap_style")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
.width(160.0)
.show_ui(ui, |ui| {
for c in &cnames {
if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))).clicked() {
d.fields.insert("p.dropCapStyle".into(), json!(c));
}
}
});
ui.end_row();
});
for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] {
let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false);
if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() {
d.fields.insert(format!("p.{key}"), json!(on));
}
}
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Styles")).font(semibold(12.0)));
let mut list: Vec<Value> = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default();
let mut changed = false;
let mut remove = None;
for (i, ns) in list.iter_mut().enumerate() {
ui.horizontal(|ui| {
let style = ns["style"].as_str().unwrap_or("[None]").to_string();
egui::ComboBox::from_id_salt(("ns_style", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
.width(110.0)
.show_ui(ui, |ui| {
for c in &cnames {
if ui
.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c)))
.clicked()
{
ns["style"] = json!(c);
changed = true;
}
}
});
let through = ns["through"].as_bool().unwrap_or(true);
egui::ComboBox::from_id_salt(("ns_thr", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if through { "through" } else { "up to" })))
.width(70.0)
.show_ui(ui, |ui| {
for (v, l) in [(true, "through"), (false, "up to")] {
if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
ns["through"] = json!(v);
changed = true;
}
}
});
let mut n = ns["count"].as_u64().unwrap_or(1) as u32;
let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| {
if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
ns["count"] = json!(n);
*changed = true;
}
};
if !crate::i18n::category_first(&app.ui.language) {
count_value(ui, ns, &mut changed);
}
let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string();
egui::ComboBox::from_id_salt(("ns_until", i))
.selected_text(crate::rtl::widget(
ui,
if crate::i18n::category_first(&app.ui.language) {
format!("{}:", crate::i18n::tr(&app.ui.language, &kind))
} else {
crate::i18n::tr(&app.ui.language, &kind).to_owned()
},
))
.width(100.0)
.show_ui(ui, |ui| {
for k in [
"sentences",
"words",
"characters",
"letters",
"digits",
"tab",
"forcedLineBreak",
"emSpace",
"enSpace",
"dropcap",
"chars",
] {
if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() {
ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) };
changed = true;
}
}
});
if crate::i18n::category_first(&app.ui.language) {
count_value(ui, ns, &mut changed);
}
if kind == "chars" {
let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() {
ns["until"]["chars"] = json!(c);
changed = true;
}
}
if ui
.small_button("×")
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete"));
})
.clicked()
{
remove = Some(i);
}
});
}
if let Some(i) = remove {
list.remove(i);
changed = true;
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Nested Style"))).clicked() {
list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}}));
changed = true;
}
if changed {
d.fields.insert("p.nestedStyles".into(), Value::Array(list));
}
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Line Styles")).font(semibold(12.0)));
let mut lines: Vec<Value> = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default();
let mut changed = false;
let mut remove = None;
for (i, l) in lines.iter_mut().enumerate() {
ui.horizontal(|ui| {
let style = l["style"].as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt(("nls_style", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
.width(130.0)
.show_ui(ui, |ui| {
for c in &cnames {
if ui
.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c)))
.clicked()
{
l["style"] = json!(c);
changed = true;
}
}
});
crate::rtl::label(
ui,
if crate::i18n::category_first(&app.ui.language) {
format!("{}:", crate::i18n::tr(&app.ui.language, "lines"))
} else {
crate::i18n::tr(&app.ui.language, "for").to_owned()
},
);
let mut n = l["lines"].as_u64().unwrap_or(1) as u32;
if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
l["lines"] = json!(n);
changed = true;
}
if !crate::i18n::category_first(&app.ui.language) {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if n == 1 { "line" } else { "lines" }));
}
if ui
.small_button("×")
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete"));
})
.clicked()
{
remove = Some(i);
}
});
}
if let Some(i) = remove {
lines.remove(i);
changed = true;
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Line Style"))).clicked() {
lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1}));
changed = true;
}
if changed {
d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines));
}
}
"grep" => {
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "GREP Styles")).font(semibold(12.0)));
let mut list: Vec<Value> = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default();
let mut changed = false;
let mut remove = None;
for (i, g) in list.iter_mut().enumerate() {
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Apply Style:"));
let style = g["style"].as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt(("gs_style", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
.width(110.0)
.show_ui(ui, |ui| {
for c in &cnames {
if ui
.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c)))
.clicked()
{
g["style"] = json!(c);
changed = true;
}
}
});
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "To Text:"));
let mut pat = g["pattern"].as_str().unwrap_or("").to_string();
let bad = grep_error(&pat).is_some();
let r = ui.add(
egui::TextEdit::singleline(&mut pat)
.desired_width(150.0)
.text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90))),
);
if r.changed() {
g["pattern"] = json!(pat);
changed = true;
}
if ui
.small_button("×")
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete"));
})
.clicked()
{
remove = Some(i);
}
});
if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) {
crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90)));
}
}
if let Some(i) = remove {
list.remove(i);
changed = true;
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New GREP Style"))).clicked() {
list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"}));
changed = true;
}
if changed {
d.fields.insert("p.grepStyles".into(), Value::Array(list));
}
}
"nested" => drop_caps_and_nested_styles(&app.ui.language, ui, d, &pv, &cnames),
"grep" => grep_styles(&app.ui.language, ui, d, &pv, &cnames),
"chars" => basic_character_formats(app, ui, d, &CharFields { base: &cv, sparse: false }),
"indents" => {
egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
@@ -4209,27 +3985,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
"tabs" => style_tabs(&app.ui.language, ui, d, &pv, &doc),
"rules" => paragraph_rules(app, ui, d, "p.", &pv),
"hyph" => {
let mut h = cur(d, "p.hyphenate", &pv["hyphenate"]).as_bool().unwrap_or(true);
if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() {
d.fields.insert("p.hyphenate".into(), json!(h));
}
egui::Grid::new("psh").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
for (label, key) in [
("Words with at Least:", "hyphMinWord"),
("After First:", "hyphAfterFirst"),
("Before Last:", "hyphBeforeLast"),
("Hyphen Limit:", "hyphLimit"),
] {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64();
if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) {
d.fields.insert(format!("p.{key}"), json!(n.max(0.0) as u64));
}
ui.end_row();
}
});
}
"hyph" => hyphenation(&app.ui.language, ui, d, "p.", &pv, units),
"justify" => justification(&app.ui.language, ui, d, "p.", &pv),
"color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc),
"advanced" => advanced_character_formats(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }),
@@ -4725,6 +4481,254 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc
});
}
/// Drop Caps and Nested Styles: the drop cap, nested styles and nested line styles (`p.` fields).
fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) {
let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone());
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Drop Caps")).font(semibold(12.0)));
egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
for (label, key, max) in [
("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES),
("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS),
] {
crate::rtl::label(ui, crate::i18n::tr(lang, label));
let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64();
if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) {
d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64)));
}
ui.end_row();
}
crate::rtl::label(ui, crate::i18n::tr(lang, "Character Style:"));
let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string();
let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style };
egui::ComboBox::from_id_salt("ps_dropcap_style")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
.width(160.0)
.show_ui(ui, |ui| {
for c in cnames {
if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
d.fields.insert("p.dropCapStyle".into(), json!(c));
}
}
});
ui.end_row();
});
for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] {
let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false);
if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).changed() {
d.fields.insert(format!("p.{key}"), json!(on));
}
}
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Styles")).font(semibold(12.0)));
let mut list: Vec<Value> = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default();
let mut changed = false;
let mut remove = None;
for (i, ns) in list.iter_mut().enumerate() {
ui.horizontal(|ui| {
let style = ns["style"].as_str().unwrap_or("[None]").to_string();
egui::ComboBox::from_id_salt(("ns_style", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
.width(110.0)
.show_ui(ui, |ui| {
for c in cnames {
if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
ns["style"] = json!(c);
changed = true;
}
}
});
let through = ns["through"].as_bool().unwrap_or(true);
egui::ComboBox::from_id_salt(("ns_thr", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if through { "through" } else { "up to" })))
.width(70.0)
.show_ui(ui, |ui| {
for (v, l) in [(true, "through"), (false, "up to")] {
if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(lang, l))).clicked() {
ns["through"] = json!(v);
changed = true;
}
}
});
let mut n = ns["count"].as_u64().unwrap_or(1) as u32;
let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| {
if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
ns["count"] = json!(n);
*changed = true;
}
};
if !crate::i18n::category_first(lang) {
count_value(ui, ns, &mut changed);
}
let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string();
egui::ComboBox::from_id_salt(("ns_until", i))
.selected_text(crate::rtl::widget(
ui,
if crate::i18n::category_first(lang) {
format!("{}:", crate::i18n::tr(lang, &kind))
} else {
crate::i18n::tr(lang, &kind).to_owned()
},
))
.width(100.0)
.show_ui(ui, |ui| {
for k in
["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "dropcap", "chars"]
{
if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(lang, k))).clicked() {
ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) };
changed = true;
}
}
});
if crate::i18n::category_first(lang) {
count_value(ui, ns, &mut changed);
}
if kind == "chars" {
let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() {
ns["until"]["chars"] = json!(c);
changed = true;
}
}
if ui
.small_button("×")
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Delete"));
})
.clicked()
{
remove = Some(i);
}
});
}
if let Some(i) = remove {
list.remove(i);
changed = true;
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New Nested Style"))).clicked() {
list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}}));
changed = true;
}
if changed {
d.fields.insert("p.nestedStyles".into(), Value::Array(list));
}
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Line Styles")).font(semibold(12.0)));
let mut lines: Vec<Value> = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default();
let mut changed = false;
let mut remove = None;
for (i, l) in lines.iter_mut().enumerate() {
ui.horizontal(|ui| {
let style = l["style"].as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt(("nls_style", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
.width(130.0)
.show_ui(ui, |ui| {
for c in cnames {
if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
l["style"] = json!(c);
changed = true;
}
}
});
crate::rtl::label(
ui,
if crate::i18n::category_first(lang) {
format!("{}:", crate::i18n::tr(lang, "lines"))
} else {
crate::i18n::tr(lang, "for").to_owned()
},
);
let mut n = l["lines"].as_u64().unwrap_or(1) as u32;
if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
l["lines"] = json!(n);
changed = true;
}
if !crate::i18n::category_first(lang) {
crate::rtl::label(ui, crate::i18n::tr(lang, if n == 1 { "line" } else { "lines" }));
}
if ui
.small_button("×")
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Delete"));
})
.clicked()
{
remove = Some(i);
}
});
}
if let Some(i) = remove {
lines.remove(i);
changed = true;
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New Line Style"))).clicked() {
lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1}));
changed = true;
}
if changed {
d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines));
}
}
/// GREP Styles: a character style applied to text matching each pattern (`p.` fields).
fn grep_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) {
let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone());
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "GREP Styles")).font(semibold(12.0)));
let mut list: Vec<Value> = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default();
let mut changed = false;
let mut remove = None;
for (i, g) in list.iter_mut().enumerate() {
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Apply Style:"));
let style = g["style"].as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt(("gs_style", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
.width(110.0)
.show_ui(ui, |ui| {
for c in cnames {
if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
g["style"] = json!(c);
changed = true;
}
}
});
crate::rtl::label(ui, crate::i18n::tr(lang, "To Text:"));
let mut pat = g["pattern"].as_str().unwrap_or("").to_string();
let bad = grep_error(&pat).is_some();
let r =
ui.add(egui::TextEdit::singleline(&mut pat).desired_width(150.0).text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90))));
if r.changed() {
g["pattern"] = json!(pat);
changed = true;
}
if ui
.small_button("×")
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Delete"));
})
.clicked()
{
remove = Some(i);
}
});
if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) {
crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90)));
}
}
if let Some(i) = remove {
list.remove(i);
changed = true;
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New GREP Style"))).clicked() {
list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"}));
changed = true;
}
if changed {
d.fields.insert("p.grepStyles".into(), Value::Array(list));
}
}
/// Paragraph Style Options › Keep Options: lines kept with the next paragraph, the paragraph's
/// lines kept together (all of them, or its first and last lines), and where it starts.
fn keep_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value) {
@@ -5134,6 +5138,15 @@ fn paragraph_local(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.set_min_width(480.0);
match d.s("section").as_str() {
"span" => span_columns(&lang, ui, d, &pv, &doc),
"keep" => keep_options(&lang, ui, d, &pv),
"nested" | "grep" => {
let cnames: Vec<String> = doc.styles.character.iter().map(|c| c.name.clone()).collect();
if d.s("section") == "nested" {
drop_caps_and_nested_styles(&lang, ui, d, &pv, &cnames);
} else {
grep_styles(&lang, ui, d, &pv, &cnames);
}
}
"bullets" => {
let (char_styles, families, fonts, scope) = ListUi::new_parts(app);
let (family, style) = (d.s("family"), d.s("style"));
@@ -5831,6 +5844,158 @@ fn check_justification(d: &Dialog, prefix: &str, current: &Value) -> Result<(),
Ok(())
}
/// Paragraph attributes the Hyphenation Settings dialog sets.
pub const HYPH_ATTRS: [&str; 10] = [
"hyphenate",
"hyphMinWord",
"hyphAfterFirst",
"hyphBeforeLast",
"hyphLimit",
"hyphZone",
"hyphWeight",
"hyphCapitalized",
"hyphLastWord",
"hyphAcrossColumn",
];
/// InDesign's Hyphenation Settings: Hyphenate; Words with at Least, After First and Before Last
/// (letters), Hyphen Limit (hyphens) and Hyphenation Zone; the Better Spacing ↔ Fewer Hyphens
/// slider (0–10, as InDesign's HyphenWeight); Hyphenate Capitalized Words, Last Word and Across
/// Column. Edited fields are stored as `{prefix}{key}`; the others show `current` (a mixed value
/// is null and shows blank). The Hyphenation dialog for the selected paragraphs and Paragraph
/// Style Options › Hyphenation both use it.
fn hyphenation(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value, units: Unit) {
let tr = |s: &str| crate::i18n::tr(lang, s).to_string();
let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone());
// Labels right-aligned against their fields, as InDesign's.
let label_right = |ui: &mut egui::Ui, text: &str| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend))
});
};
let checkbox = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {
let v = cur(d, key);
// Mixed shows indeterminate and starts off, so the first click turns it on for all.
let mut on = v.as_bool().unwrap_or(false);
if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, tr(label))).indeterminate(v.is_null())).changed() {
d.fields.insert(format!("{prefix}{key}"), json!(on));
}
};
checkbox(ui, d, "hyphenate", "Hyphenate");
ui.add_space(4.0);
egui::Grid::new(("hyph", prefix)).num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| {
// InDesign's ranges.
for (label, key, unit, max) in [
("Words with at Least:", "hyphMinWord", "letters", 25.0),
("After First:", "hyphAfterFirst", "letters", 15.0),
("Before Last:", "hyphBeforeLast", "letters", 15.0),
("Hyphen Limit:", "hyphLimit", "hyphens", 25.0),
] {
label_right(ui, &tr(label));
let min = if key == "hyphLimit" { 0.0 } else { 1.0 };
if let Some(n) = crate::widgets::spin_number(ui, &format!("hy{prefix}{key}"), cur(d, key).as_f64(), "", 60.0, 0) {
d.fields.insert(format!("{prefix}{key}"), json!(n.round().clamp(min, max) as u64));
}
crate::rtl::label(ui, tr(unit));
ui.end_row();
}
label_right(ui, &tr("Hyphenation Zone:"));
if let Some(n) = crate::widgets::spin_measure(ui, &format!("hy{prefix}zone"), cur(d, "hyphZone").as_f64(), units, 60.0) {
d.fields.insert(format!("{prefix}hyphZone"), json!(if n.is_finite() { n.clamp(0.0, 8640.0) } else { 36.0 }));
}
ui.end_row();
});
ui.add_space(6.0);
// Better Spacing ↔ Fewer Hyphens, in InDesign's eleven steps.
ui.horizontal(|ui| {
crate::rtl::label(ui, tr("Better Spacing"));
let w = cur(d, "hyphWeight").as_f64();
let mut step = w.map_or(5.0, |w| (w * 10.0).round()).clamp(0.0, 10.0);
// Mixed across the selection: the slider shows dimmed, and a click or drag on it, even
// one that leaves the knob in place, sets every paragraph to that step.
let mixed = w.is_none();
// The ticks go under the knob: their place in the paint order is taken first.
let ticks = ui.painter().add(egui::Shape::Noop);
let r = ui
.scope(|ui| {
ui.spacing_mut().slider_width = 180.0;
if mixed {
ui.multiply_opacity(0.4);
}
ui.add(egui::Slider::new(&mut step, 0.0..=10.0).step_by(1.0).show_value(false).handle_shape(egui::style::HandleShape::Circle))
})
.inner;
// The tick marks under the track.
let t = crate::theme::Tokens::get(ui.ctx());
let inner = r.rect.shrink2(egui::vec2(r.rect.height() / 2.0, 0.0));
let y = r.rect.center().y;
let marks: Vec<egui::Shape> = (0..=10)
.map(|i| {
let x = inner.left() + inner.width() * i as f32 / 10.0;
egui::Shape::line_segment([egui::pos2(x, y - 8.0), egui::pos2(x, y + 8.0)], egui::Stroke::new(1.0, t.text_dim))
})
.collect();
ui.painter().set(ticks, egui::Shape::Vec(marks));
if r.changed() || (mixed && (r.clicked() || r.drag_stopped())) {
d.fields.insert(format!("{prefix}hyphWeight"), json!(step / 10.0));
}
crate::rtl::label(ui, tr("Fewer Hyphens"));
});
ui.add_space(6.0);
egui::Grid::new(("hyphc", prefix)).num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| {
checkbox(ui, d, "hyphCapitalized", "Hyphenate Capitalized Words");
checkbox(ui, d, "hyphLastWord", "Hyphenate Last Word");
ui.end_row();
checkbox(ui, d, "hyphAcrossColumn", "Hyphenate Across Column");
ui.end_row();
});
}
/// The Hyphenation dialog's edits, as `type.para` attrs.
fn hyphenation_edits(d: &Dialog) -> Map<String, Value> {
HYPH_ATTRS.iter().filter_map(|k| d.fields.get(*k).map(|v| (k.to_string(), v.clone()))).collect()
}
/// Preview on: the edits show on the page (redone when a field changes) without an undo step;
/// off, the paragraphs go back.
fn hyphenation_preview(app: &mut DesignApp, d: &mut Dialog) {
let shown = d.fields.get(PREVIEWED).cloned();
if !d.b("preview") {
if shown.is_some() {
app.session.cancel_interaction();
d.fields.remove(PREVIEWED);
}
return;
}
let params = json!({"attrs": hyphenation_edits(d)});
if shown.as_ref() == Some(&params) || d.fields.get(PREVIEW_FAILED) == Some(&params) {
return;
}
if shown.is_none()
&& let Err(e) = app.session.begin_interaction("Hyphenation")
{
d.fields.insert("status".into(), json!(e.to_string()));
return;
}
match app.session.preview("type.para", params.clone()) {
Ok(_) => {
d.fields.remove("status");
d.fields.remove(PREVIEW_FAILED);
d.fields.insert(PREVIEWED.into(), params);
}
// Rejected settings: the paragraphs go back, and the reason shows until they change.
Err(e) => {
app.session.cancel_interaction();
d.fields.remove(PREVIEWED);
d.fields.insert("status".into(), json!(e.to_string()));
d.fields.insert(PREVIEW_FAILED.into(), params);
}
}
}
/// The hyphenation settings a preview was refused for (not tried again until they change).
const PREVIEW_FAILED: &str = "previewFailed";
/// The Justification controls (the Justification dialog, and the Paragraph Style Options section).
/// `current` holds the resolved paragraph attributes, `null` where the selected paragraphs differ
/// (the field shows empty); each edit is stored as `{prefix}{attr}`, so OK changes only the fields
@@ -7656,6 +7821,146 @@ mod tests {
app.run("type.selectionAttrs", json!({})).unwrap()["para"][key].clone()
}
/// The hyphenation video (Graphd Tutorials, 0:39–1:13): the Paragraph panel menu's
/// Hyphenation… opens InDesign's Hyphenation Settings for the selected paragraphs. Preview
/// shows the slider moved to Fewer Hyphens on the page without an undo step, OK makes it one
/// step on the selected paragraph only, and Cancel puts it back.
#[test]
fn hyphenation_settings_preview_apply_and_cancel_on_the_selected_paragraph() {
let (mut app, story) = two_paragraphs();
let ctx = dialog_context("en");
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
let sid = designcraft_doc::StoryId(story.as_u64().unwrap());
let weight = |app: &DesignApp, para: usize| {
let d = &app.session.doc().unwrap().doc;
d.styles.resolve_para(&d.story(sid).unwrap().paras[para]).0.hyph_weight
};
assert!(crate::menus::menu_enabled(&app, "app.hyphenationDialog"));
app.run("app.hyphenationDialog", json!({})).unwrap();
for _ in 0..3 {
dialog_frame(&mut app, &ctx, screen, vec![]);
}
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let d = app.ui.dialog.clone().unwrap();
assert_eq!(
(d.id.as_str(), d.fields["current"]["hyphWeight"].as_f64(), d.fields["current"]["hyphenate"].as_bool()),
("hyphenation", Some(0.5), Some(true))
);
for label in [
"Hyphenation Settings",
"Hyphenate",
"Words with at Least:",
"letters",
"Hyphen Limit:",
"hyphens",
"Hyphenation Zone:",
"Better Spacing",
"Fewer Hyphens",
"Hyphenate Capitalized Words",
"Hyphenate Last Word",
"Hyphenate Across Column",
"Preview",
] {
assert!(labels.iter().any(|(t, _, _)| t == label), "{label}: {labels:?}");
}
// Fewer Hyphens (9 of 10) with Preview: on the page, no undo step.
let before = undo_steps(&app);
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("hyphWeight".into(), json!(0.9));
d.fields.insert("preview".into(), json!(true));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert_eq!(weight(&app, 0), 0.9, "previewed");
assert_eq!(undo_steps(&app), before, "a preview is no step");
// Cancel: back as it was.
cancel(&mut app);
assert_eq!(weight(&app, 0), 0.5);
// Again, and OK: one step, the selected paragraph only.
app.run("app.hyphenationDialog", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("hyphWeight".into(), json!(0.9));
d.fields.insert("preview".into(), json!(true));
dialog_frame(&mut app, &ctx, screen, vec![]);
confirm(&mut app).unwrap();
assert_eq!((weight(&app, 0), weight(&app, 1)), (0.9, 0.5));
assert_eq!(undo_steps(&app), before + 1, "one undo step");
// Paragraph Style Options › Hyphenation has the same form.
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": designcraft_doc::story::BASIC_PARAGRAPH, "section": "hyph"})));
for _ in 0..3 {
dialog_frame(&mut app, &ctx, screen, vec![]);
}
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
for label in ["Hyphenation Zone:", "Better Spacing", "Fewer Hyphens", "Hyphenate Last Word", "Hyphenate Across Column"] {
assert!(labels.iter().any(|(t, _, _)| t == label), "style options: {label}");
}
app.ui.dialog = None;
app.run("edit.deselectAll", json!({})).unwrap();
assert!(!crate::menus::menu_enabled(&app, "app.hyphenationDialog"), "no paragraph");
}
/// Hyphenate differs across the selection: it shows mixed, and the first click turns it on for
/// all. Settings the command refuses show why: a preview goes back and isn't retried until they
/// change, and OK keeps the dialog open.
/// A mixed Better Spacing ↔ Fewer Hyphens slider: a click on its middle step sets every
/// selected paragraph to it, though the knob already sat there.
#[test]
fn hyphenation_settings_mixed_slider_click_sets_the_step() {
let (mut app, story) = two_paragraphs();
let ctx = dialog_context("en");
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
app.run("type.para", json!({"attrs": {"hyphWeight": 0.2}})).unwrap();
app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap();
app.run("app.hyphenationDialog", json!({})).unwrap();
for _ in 0..3 {
dialog_frame(&mut app, &ctx, screen, vec![]);
}
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let rect = |l: &str| labels.iter().find(|(t, _, _)| t == l).map(|(_, r, _)| *r).unwrap();
let (better, fewer) = (rect("Better Spacing"), rect("Fewer Hyphens"));
click_dialog(&mut app, &ctx, screen, egui::pos2((better.right() + fewer.left()) / 2.0, better.center().y));
assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("hyphWeight"), Some(&json!(0.5)));
}
#[test]
fn hyphenation_settings_mixed_checkbox_and_refused_settings() {
let (mut app, story) = two_paragraphs();
let ctx = dialog_context("en");
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
app.run("type.para", json!({"attrs": {"hyphenate": false}})).unwrap();
app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap();
app.run("app.hyphenationDialog", json!({})).unwrap();
assert!(app.ui.dialog.as_ref().unwrap().fields["current"]["hyphenate"].is_null(), "mixed");
for _ in 0..3 {
dialog_frame(&mut app, &ctx, screen, vec![]);
}
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let at = labels.iter().find(|(t, _, _)| t == "Hyphenate").map(|(_, r, _)| r.left_center() - egui::vec2(10.0, 0.0)).unwrap();
click_dialog(&mut app, &ctx, screen, at);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("hyphenate"), Some(&json!(true)), "the first click turns it on");
// A refused setting (as a control-channel caller could send it): no preview, the reason.
let before = undo_steps(&app);
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("hyphWeight".into(), json!("heavy"));
d.fields.insert("preview".into(), json!(true));
dialog_frame(&mut app, &ctx, screen, vec![]);
let d = app.ui.dialog.as_ref().unwrap();
assert!(!d.fields.contains_key(PREVIEWED) && d.fields.contains_key("status"), "{:?}", d.fields);
assert!(app.session.doc().unwrap().interaction.is_none(), "the preview went back");
dialog_frame(&mut app, &ctx, screen, vec![]);
assert!(app.session.doc().unwrap().interaction.is_none(), "not tried again");
// OK with it: an error, and the dialog stays open with the reason.
assert!(confirm(&mut app).is_err());
let d = app.ui.dialog.as_ref().expect("the dialog stays open");
assert!(d.fields.contains_key("status"));
assert_eq!(undo_steps(&app), before);
// Corrected, it previews and applies.
app.ui.dialog.as_mut().unwrap().fields.insert("hyphWeight".into(), json!(0.9));
dialog_frame(&mut app, &ctx, screen, vec![]);
assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("status"));
confirm(&mut app).unwrap();
assert_eq!(para_attr(&mut app, &story, 0, "hyphenate"), true);
assert_eq!(para_attr(&mut app, &story, 4, "hyphWeight"), 0.9);
}
#[test]
fn justification_dialog_applies_to_a_local_paragraph_and_cancel_changes_nothing() {
let (mut app, story) = two_paragraphs();
+163
View File
@@ -46,6 +46,71 @@ pub fn picker_languages() -> Vec<(&'static str, &'static str)> {
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
const TABLE: &[(&str, [&str; 5])] = &[
(
"Character Formatting Controls",
[
"Steuerelemente für Zeichenformatierung",
"Commandes de mise en forme des caractères",
"Controles de formato de carácter",
"文字形式コントロール",
"字符格式控制",
],
),
(
"Paragraph Formatting Controls",
[
"Steuerelemente für Absatzformatierung",
"Commandes de mise en forme des paragraphes",
"Controles de formato de párrafo",
"段落形式コントロール",
"段落格式控制",
],
),
("Font Size", ["Schriftgrad", "Corps", "Tamaño de fuente", "フォントサイズ", "字体大小"]),
("Vertical Scale", ["Vertikal skalieren", "Échelle verticale", "Escala vertical", "垂直比率", "垂直缩放"]),
("Horizontal Scale", ["Horizontal skalieren", "Échelle horizontale", "Escala horizontal", "水平比率", "水平缩放"]),
("Baseline Shift", ["Grundlinienversatz", "Décalage vertical", "Desplazamiento vertical", "ベースラインシフト", "基线偏移"]),
(
"Skew (False Italic)",
["Verzerren (falscher Kursivstil)", "Inclinaison (faux italique)", "Sesgar (cursiva falsa)", "歪み(斜体)", "倾斜(伪斜体)"],
),
("Left Indent", ["Einzug links", "Retrait gauche", "Sangría izquierda", "左インデント", "左缩进"]),
("Right Indent", ["Einzug rechts", "Retrait droit", "Sangría derecha", "右インデント", "右缩进"]),
(
"First Line Left Indent",
["Einzug links in erster Zeile", "Retrait gauche de première ligne", "Sangría izquierda en primera línea", "1行目左インデント", "首行左缩进"],
),
(
"Last Line Right Indent",
["Einzug rechts in letzter Zeile", "Retrait droit de dernière ligne", "Sangría derecha en última línea", "最終行右インデント", "末行右缩进"],
),
(
"Drop Cap Number of Lines",
["Initiale: Anzahl der Zeilen", "Lettrine (nombre de lignes)", "Capitular: número de líneas", "ドロップキャップの行数", "首字下沉行数"],
),
(
"Drop Cap One or More Characters",
[
"Initiale: ein oder mehrere Zeichen",
"Lettrine (un ou plusieurs caractères)",
"Capitular: uno o más caracteres",
"ドロップキャップの文字数",
"首字下沉一个或多个字符",
],
),
(
"Align to Baseline Grid",
[
"An Grundlinienraster ausrichten",
"Aligner sur la grille de ligne de base",
"Alinear con cuadrícula base",
"ベースライングリッドに揃える",
"对齐基线网格",
],
),
// Properties › Paragraph (•••).
("Space Before", ["Abstand davor", "Espace avant", "Espacio anterior", "段落前の間隔", "段前间距"]),
("Space After", ["Abstand danach", "Espace après", "Espacio posterior", "段落後の間隔", "段后间距"]),
// Menus.
("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]),
("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]),
@@ -1010,6 +1075,102 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]),
("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]),
("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]),
("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]),
(
"Space Between Paragraphs Using Same Style",
[
"Abstand zwischen Absätzen mit gleichem Format",
"Espace entre les paragraphes de même style",
"Espacio entre párrafos con el mismo estilo",
"同じスタイルの段落間のスペース",
"使用相同样式的段落间距",
],
),
("Ignore", ["Ignorieren", "Ignorer", "Ignorar", "無視", "忽略"]),
(
"Do Not Align to Baseline Grid",
[
"Nicht am Grundlinienraster ausrichten",
"Ne pas aligner sur la grille de ligne de base",
"No alinear con cuadrícula base",
"ベースライングリッドに揃えない",
"不对齐基线网格",
],
),
(
"Only Align First Line to Grid",
[
"Nur erste Zeile am Raster ausrichten",
"Aligner uniquement la première ligne sur la grille",
"Alinear solo primera línea con cuadrícula",
"最初の行のみグリッドに揃える",
"仅首行对齐网格",
],
),
(
"Balance Ragged Lines",
["Unausgeglichene Zeilen ausgleichen", "Équilibrer les lignes irrégulières", "Equilibrar líneas desiguales", "行末を調整", "平衡未对齐的行"],
),
(
"Drop Caps and Nested Styles…",
[
"Initialen und verschachtelte Formate …",
"Lettrines et styles imbriqués…",
"Capitulares y estilos anidados…",
"ドロップキャップと先頭文字スタイル…",
"首字下沉和嵌套样式…",
],
),
("GREP Styles…", ["GREP-Stile …", "Styles GREP…", "Estilos GREP…", "正規表現スタイル…", "GREP 样式…"]),
(
"Restart/Continue Numbering",
[
"Nummerierung neu beginnen/fortführen",
"Recommencer/Poursuivre la numérotation",
"Reiniciar/Continuar numeración",
"番号の振り直し/継続",
"重新开始/继续编号",
],
),
(
"Convert Bullets and Numbering to Text",
[
"Aufzählungszeichen und Nummerierung in Text konvertieren",
"Convertir les puces et la numérotation en texte",
"Convertir viñetas y numeración en texto",
"箇条書きと段落番号をテキストに変換",
"将项目符号和编号转换为文本",
],
),
("Hyphenation…", ["Silbentrennung …", "Césure…", "Separación por sílabas…", "ハイフネーション…", "连字…"]),
(
"Hyphenation Settings",
["Silbentrennungseinstellungen", "Paramètres de césure", "Configuración de separación por sílabas", "ハイフネーション設定", "连字设置"],
),
("hyphens", ["Trennstriche", "tirets", "guiones", "ハイフン", "连字符"]),
("Hyphenation Zone:", ["Silbentrennungsbereich:", "Zone de césure :", "Zona de separación:", "ハイフネーション領域 :", "连字区:"]),
("Better Spacing", ["Bessere Abstände", "Meilleur espacement", "Mejor espaciado", "スペースを優先", "更好的间距"]),
("Fewer Hyphens", ["Weniger Trennstriche", "Moins de tirets", "Menos guiones", "ハイフンを減らす", "更少的连字符"]),
(
"Hyphenate Capitalized Words",
[
"Großgeschriebene Wörter trennen",
"Césure des mots en capitales",
"Separar palabras en mayúscula",
"大文字で始まる単語をハイフネーション",
"连字大写的单词",
],
),
(
"Hyphenate Last Word",
["Letztes Wort trennen", "Césure du dernier mot", "Separar última palabra", "最後の単語をハイフネーション", "连字最后一个单词"],
),
(
"Hyphenate Across Column",
["Über Spalten trennen", "Césure entre colonnes", "Separar entre columnas", "段をまたいでハイフネーション", "跨栏连字"],
),
(
"Differs from this document",
["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"],
@@ -1648,6 +1809,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Define Lists…", ["Define Lists…", "Définir des listes…", "Define Lists…", "Define Lists…", "定义列表…"]),
("Apply Bullets", ["Aufzählungszeichen anwenden", "Appliquer des puces", "Aplicar viñetas", "箇条書きを適用", "应用项目符号"]),
("Apply Numbers", ["Nummerierung anwenden", "Appliquer la numérotation", "Aplicar números", "段落番号を適用", "应用编号"]),
(
"Define Text Variable…",
["Define Text Variable…", "Définir une variable de texte…", "Define Text Variable…", "Define Text Variable…", "定义文本变量…"],
+37
View File
@@ -30,6 +30,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "قص"),
("Copy", "نسخ"),
("Paste", "لصق"),
("Restart Numbering", "إعادة الترقيم"),
("Continue Numbering", "متابعة الترقيم"),
("Keep Options…", "خيارات الإبقاء…"),
("Space Between Paragraphs Using Same Style", "المسافة بين الفقرات ذات النمط نفسه"),
("Ignore", "تجاهل"),
("Do Not Align to Baseline Grid", "عدم المحاذاة إلى شبكة خط الأساس"),
("Only Align First Line to Grid", "محاذاة السطر الأول فقط إلى الشبكة"),
("Balance Ragged Lines", "موازنة الأسطر غير المتساوية"),
("Drop Caps and Nested Styles…", "الأحرف الاستهلالية والأنماط المتداخلة…"),
("GREP Styles…", "أنماط GREP…"),
("Restart/Continue Numbering", "إعادة/متابعة الترقيم"),
("Convert Bullets and Numbering to Text", "تحويل التعداد النقطي والترقيم إلى نص"),
("Hyphenation…", "الواصلة…"),
("Hyphenation Settings", "إعدادات الواصلة"),
("hyphens", "واصلات"),
("Hyphenation Zone:", "منطقة الواصلة:"),
("Better Spacing", "تباعد أفضل"),
("Fewer Hyphens", "واصلات أقل"),
("Hyphenate Capitalized Words", "وصل الكلمات ذات الأحرف الكبيرة"),
("Hyphenate Last Word", "وصل الكلمة الأخيرة"),
("Hyphenate Across Column", "الوصل عبر الأعمدة"),
("Character Formatting Controls", "عناصر تحكم تنسيق الأحرف"),
("Paragraph Formatting Controls", "عناصر تحكم تنسيق الفقرات"),
("Font Size", "حجم الخط"),
("Vertical Scale", "المقياس الرأسي"),
("Horizontal Scale", "المقياس الأفقي"),
("Baseline Shift", "إزاحة خط الأساس"),
("Skew (False Italic)", "إمالة (مائل زائف)"),
("Left Indent", "مسافة بادئة يسرى"),
("Right Indent", "مسافة بادئة يمنى"),
("First Line Left Indent", "مسافة بادئة يسرى للسطر الأول"),
("Last Line Right Indent", "مسافة بادئة يمنى للسطر الأخير"),
("Drop Cap Number of Lines", "عدد أسطر الحرف الاستهلالي"),
("Drop Cap One or More Characters", "حرف استهلالي واحد أو أكثر"),
("Align to Baseline Grid", "محاذاة إلى شبكة خط الأساس"),
("Apply Bullets", "تطبيق التعداد النقطي"),
("Apply Numbers", "تطبيق الترقيم"),
("Paste Vector Art", "لصق رسم متجه"),
("Baseline Options", "خيارات خط الأساس"),
("Fixed Number", "عدد ثابت"),
+37
View File
@@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Vyjmout"),
("Copy", "Kopírovat"),
("Paste", "Vložit"),
("Restart Numbering", "Začít číslování znovu"),
("Continue Numbering", "Pokračovat v číslování"),
("Keep Options…", "Volby zachování…"),
("Space Between Paragraphs Using Same Style", "Mezera mezi odstavci se stejným stylem"),
("Ignore", "Ignorovat"),
("Do Not Align to Baseline Grid", "Nezarovnávat na mřížku účaří"),
("Only Align First Line to Grid", "Zarovnat na mřížku jen první řádek"),
("Balance Ragged Lines", "Vyrovnat nezarovnané řádky"),
("Drop Caps and Nested Styles…", "Iniciály a vnořené styly…"),
("GREP Styles…", "Styly GREP…"),
("Restart/Continue Numbering", "Začít znovu/pokračovat v číslování"),
("Convert Bullets and Numbering to Text", "Převést odrážky a číslování na text"),
("Hyphenation…", "Dělení slov…"),
("Hyphenation Settings", "Nastavení dělení slov"),
("hyphens", "spojovníky"),
("Hyphenation Zone:", "Zóna dělení:"),
("Better Spacing", "Lepší mezery"),
("Fewer Hyphens", "Méně spojovníků"),
("Hyphenate Capitalized Words", "Dělit slova s velkým počátečním písmenem"),
("Hyphenate Last Word", "Dělit poslední slovo"),
("Hyphenate Across Column", "Dělit přes sloupce"),
("Character Formatting Controls", "Ovládací prvky formátování znaků"),
("Paragraph Formatting Controls", "Ovládací prvky formátování odstavců"),
("Font Size", "Velikost písma"),
("Vertical Scale", "Svislá změna velikosti"),
("Horizontal Scale", "Vodorovná změna velikosti"),
("Baseline Shift", "Posun účaří"),
("Skew (False Italic)", "Zkosení (nepravá kurzíva)"),
("Left Indent", "Odsazení zleva"),
("Right Indent", "Odsazení zprava"),
("First Line Left Indent", "Odsazení prvního řádku zleva"),
("Last Line Right Indent", "Odsazení posledního řádku zprava"),
("Drop Cap Number of Lines", "Počet řádků iniciály"),
("Drop Cap One or More Characters", "Iniciála: jeden nebo více znaků"),
("Align to Baseline Grid", "Zarovnat na mřížku účaří"),
("Apply Bullets", "Použít odrážky"),
("Apply Numbers", "Použít číslování"),
("Reverse Path", "Obrátit cestu"),
("Set Anchor", "Nastavit kotvu"),
("Clear", "Vymazat"),
+23
View File
@@ -31,6 +31,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Ausschneiden"),
("Copy", "Kopieren"),
("Paste", "Einfügen"),
("Restart Numbering", "Nummerierung neu beginnen"),
("Continue Numbering", "Nummerierung fortführen"),
("Keep Options…", "Umbruchoptionen …"),
("Space Between Paragraphs Using Same Style", "Abstand zwischen Absätzen mit gleichem Format"),
("Ignore", "Ignorieren"),
("Do Not Align to Baseline Grid", "Nicht am Grundlinienraster ausrichten"),
("Only Align First Line to Grid", "Nur erste Zeile am Raster ausrichten"),
("Balance Ragged Lines", "Unausgeglichene Zeilen ausgleichen"),
("Drop Caps and Nested Styles…", "Initialen und verschachtelte Formate …"),
("GREP Styles…", "GREP-Stile …"),
("Restart/Continue Numbering", "Nummerierung neu beginnen/fortführen"),
("Convert Bullets and Numbering to Text", "Aufzählungszeichen und Nummerierung in Text konvertieren"),
("Hyphenation…", "Silbentrennung …"),
("Hyphenation Settings", "Silbentrennungseinstellungen"),
("hyphens", "Trennstriche"),
("Hyphenation Zone:", "Silbentrennungsbereich:"),
("Better Spacing", "Bessere Abstände"),
("Fewer Hyphens", "Weniger Trennstriche"),
("Hyphenate Capitalized Words", "Großgeschriebene Wörter trennen"),
("Hyphenate Last Word", "Letztes Wort trennen"),
("Hyphenate Across Column", "Über Spalten trennen"),
("Apply Bullets", "Aufzählungszeichen anwenden"),
("Apply Numbers", "Nummerierung anwenden"),
("Inches Decimal", "Zoll dezimal"),
("Back", "Zurück"),
("Warning", "Warnung"),
-2
View File
@@ -337,8 +337,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Sixth Space", "Sixième d'espace"),
("Smart Guide Options", "Options des repères commentés"),
("Smooth Tool", "Outil Lissage"),
("Space After", "Espace après"),
("Space Before", "Espace avant"),
("Start Page #", "N° de la première page"),
("Step and Repeat", "Exécuter et répéter"),
("Stop Preview", "Arrêter l'aperçu"),
+37
View File
@@ -28,6 +28,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Taglia"),
("Copy", "Copia"),
("Paste", "Incolla"),
("Restart Numbering", "Riavvia numerazione"),
("Continue Numbering", "Continua numerazione"),
("Keep Options…", "Opzioni di separazione…"),
("Space Between Paragraphs Using Same Style", "Spazio tra paragrafi con lo stesso stile"),
("Ignore", "Ignora"),
("Do Not Align to Baseline Grid", "Non allineare alla griglia linee di base"),
("Only Align First Line to Grid", "Allinea solo la prima riga alla griglia"),
("Balance Ragged Lines", "Bilancia righe irregolari"),
("Drop Caps and Nested Styles…", "Capilettera e stili nidificati…"),
("GREP Styles…", "Stili GREP…"),
("Restart/Continue Numbering", "Riavvia/continua numerazione"),
("Convert Bullets and Numbering to Text", "Converti elenchi puntati e numerati in testo"),
("Hyphenation…", "Sillabazione…"),
("Hyphenation Settings", "Impostazioni sillabazione"),
("hyphens", "trattini"),
("Hyphenation Zone:", "Area di sillabazione:"),
("Better Spacing", "Spaziatura migliore"),
("Fewer Hyphens", "Meno trattini"),
("Hyphenate Capitalized Words", "Sillaba parole con iniziale maiuscola"),
("Hyphenate Last Word", "Sillaba ultima parola"),
("Hyphenate Across Column", "Sillaba tra colonne"),
("Character Formatting Controls", "Controlli di formattazione carattere"),
("Paragraph Formatting Controls", "Controlli di formattazione paragrafo"),
("Font Size", "Dimensione font"),
("Vertical Scale", "Scala verticale"),
("Horizontal Scale", "Scala orizzontale"),
("Baseline Shift", "Spostamento linea di base"),
("Skew (False Italic)", "Inclinazione (falso corsivo)"),
("Left Indent", "Rientro sinistro"),
("Right Indent", "Rientro destro"),
("First Line Left Indent", "Rientro sinistro prima riga"),
("Last Line Right Indent", "Rientro destro ultima riga"),
("Drop Cap Number of Lines", "Capolettera: numero di righe"),
("Drop Cap One or More Characters", "Capolettera: uno o più caratteri"),
("Align to Baseline Grid", "Allinea alla griglia di base"),
("Apply Bullets", "Applica punti elenco"),
("Apply Numbers", "Applica numeri"),
("Paste Vector Art", "Incolla grafica vettoriale"),
("Baseline Options", "Opzioni linea di base"),
("Fixed Number", "Numero fisso"),
+23
View File
@@ -30,6 +30,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "カット"),
("Copy", "コピー"),
("Paste", "ペースト"),
("Restart Numbering", "番号の振り直し"),
("Continue Numbering", "番号の継続"),
("Keep Options…", "段落分離禁止オプション…"),
("Space Between Paragraphs Using Same Style", "同じスタイルの段落間のスペース"),
("Ignore", "無視"),
("Do Not Align to Baseline Grid", "ベースライングリッドに揃えない"),
("Only Align First Line to Grid", "最初の行のみグリッドに揃える"),
("Balance Ragged Lines", "行末を調整"),
("Drop Caps and Nested Styles…", "ドロップキャップと先頭文字スタイル…"),
("GREP Styles…", "正規表現スタイル…"),
("Restart/Continue Numbering", "番号の振り直し/継続"),
("Convert Bullets and Numbering to Text", "箇条書きと段落番号をテキストに変換"),
("Hyphenation…", "ハイフネーション…"),
("Hyphenation Settings", "ハイフネーション設定"),
("hyphens", "ハイフン"),
("Hyphenation Zone:", "ハイフネーション領域 :"),
("Better Spacing", "スペースを優先"),
("Fewer Hyphens", "ハイフンを減らす"),
("Hyphenate Capitalized Words", "大文字で始まる単語をハイフネーション"),
("Hyphenate Last Word", "最後の単語をハイフネーション"),
("Hyphenate Across Column", "段をまたいでハイフネーション"),
("Apply Bullets", "箇条書きを適用"),
("Apply Numbers", "段落番号を適用"),
("Paste Vector Art", "ベクトルアートをペースト"),
("Baseline Options", "ベースラインオプション"),
("Column Rules", "段落境界線"),
+37
View File
@@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "ამოჭრა"),
("Copy", "კოპირება"),
("Paste", "ჩასმა"),
("Restart Numbering", "ნუმერაციის თავიდან დაწყება"),
("Continue Numbering", "ნუმერაციის გაგრძელება"),
("Keep Options…", "შენარჩუნების პარამეტრები…"),
("Space Between Paragraphs Using Same Style", "დაშორება ერთი სტილის აბზაცებს შორის"),
("Ignore", "უგულებელყოფა"),
("Do Not Align to Baseline Grid", "საბაზისო ბადეზე არ გასწორდეს"),
("Only Align First Line to Grid", "ბადეზე მხოლოდ პირველი ხაზის გასწორება"),
("Balance Ragged Lines", "უსწორმასწორო ხაზების დაბალანსება"),
("Drop Caps and Nested Styles…", "საწყისი ასოები და ჩადგმული სტილები…"),
("GREP Styles…", "GREP სტილები…"),
("Restart/Continue Numbering", "ნუმერაციის თავიდან დაწყება/გაგრძელება"),
("Convert Bullets and Numbering to Text", "მარკერებისა და ნუმერაციის ტექსტად გარდაქმნა"),
("Hyphenation…", "დამარცვლა…"),
("Hyphenation Settings", "დამარცვლის პარამეტრები"),
("hyphens", "დეფისი"),
("Hyphenation Zone:", "დამარცვლის ზონა:"),
("Better Spacing", "უკეთესი დაშორება"),
("Fewer Hyphens", "ნაკლები დეფისი"),
("Hyphenate Capitalized Words", "მთავრული სიტყვების დამარცვლა"),
("Hyphenate Last Word", "ბოლო სიტყვის დამარცვლა"),
("Hyphenate Across Column", "სვეტებს შორის დამარცვლა"),
("Character Formatting Controls", "სიმბოლოების ფორმატირების მართვა"),
("Paragraph Formatting Controls", "აბზაცების ფორმატირების მართვა"),
("Font Size", "შრიფტის ზომა"),
("Vertical Scale", "ვერტიკალური მასშტაბი"),
("Horizontal Scale", "ჰორიზონტალური მასშტაბი"),
("Baseline Shift", "საბაზო ხაზის წანაცვლება"),
("Skew (False Italic)", "დახრა (ცრუ კურსივი)"),
("Left Indent", "მარცხენა შეწევა"),
("Right Indent", "მარჯვენა შეწევა"),
("First Line Left Indent", "პირველი სტრიქონის მარცხენა შეწევა"),
("Last Line Right Indent", "ბოლო სტრიქონის მარჯვენა შეწევა"),
("Drop Cap Number of Lines", "ინიციალის სტრიქონების რაოდენობა"),
("Drop Cap One or More Characters", "ინიციალი: ერთი ან მეტი სიმბოლო"),
("Align to Baseline Grid", "საბაზო ბადეზე სწორება"),
("Apply Bullets", "მარკერების გამოყენება"),
("Apply Numbers", "ნუმერაციის გამოყენება"),
("Inches Decimal", "ინჩები (ათობითი)"),
("Back", "უკან"),
("Warning", "გაფრთხილება"),
+37
View File
@@ -29,6 +29,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Wytnij"),
("Copy", "Kopiuj"),
("Paste", "Wklej"),
("Restart Numbering", "Zacznij numerowanie od nowa"),
("Continue Numbering", "Kontynuuj numerowanie"),
("Keep Options…", "Opcje utrzymywania…"),
("Space Between Paragraphs Using Same Style", "Odstęp między akapitami o tym samym stylu"),
("Ignore", "Ignoruj"),
("Do Not Align to Baseline Grid", "Nie wyrównuj do siatki linii bazowych"),
("Only Align First Line to Grid", "Wyrównaj do siatki tylko pierwszy wiersz"),
("Balance Ragged Lines", "Wyrównaj nierówne wiersze"),
("Drop Caps and Nested Styles…", "Inicjały i style zagnieżdżone…"),
("GREP Styles…", "Style GREP…"),
("Restart/Continue Numbering", "Zacznij od nowa/kontynuuj numerowanie"),
("Convert Bullets and Numbering to Text", "Konwertuj wypunktowanie i numerowanie na tekst"),
("Hyphenation…", "Dzielenie wyrazów…"),
("Hyphenation Settings", "Ustawienia dzielenia wyrazów"),
("hyphens", "dywizy"),
("Hyphenation Zone:", "Strefa dzielenia:"),
("Better Spacing", "Lepsze odstępy"),
("Fewer Hyphens", "Mniej dywizów"),
("Hyphenate Capitalized Words", "Dziel wyrazy pisane wielką literą"),
("Hyphenate Last Word", "Dziel ostatni wyraz"),
("Hyphenate Across Column", "Dziel między łamami"),
("Character Formatting Controls", "Elementy formatowania znaków"),
("Paragraph Formatting Controls", "Elementy formatowania akapitów"),
("Font Size", "Rozmiar czcionki"),
("Vertical Scale", "Skala pionowa"),
("Horizontal Scale", "Skala pozioma"),
("Baseline Shift", "Przesunięcie linii bazowej"),
("Skew (False Italic)", "Pochylenie (fałszywa kursywa)"),
("Left Indent", "Wcięcie z lewej"),
("Right Indent", "Wcięcie z prawej"),
("First Line Left Indent", "Wcięcie pierwszego wiersza z lewej"),
("Last Line Right Indent", "Wcięcie ostatniego wiersza z prawej"),
("Drop Cap Number of Lines", "Inicjał: liczba wierszy"),
("Drop Cap One or More Characters", "Inicjał: jeden lub więcej znaków"),
("Align to Baseline Grid", "Wyrównaj do siatki linii bazowych"),
("Apply Bullets", "Zastosuj wypunktowanie"),
("Apply Numbers", "Zastosuj numerowanie"),
("Clear", "Wyczyść"),
("Duplicate", "Powiel"),
("Select All", "Zaznacz wszystko"),
+37
View File
@@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Recortar"),
("Copy", "Copiar"),
("Paste", "Colar"),
("Restart Numbering", "Reiniciar numeração"),
("Continue Numbering", "Continuar numeração"),
("Keep Options…", "Opções de manutenção…"),
("Space Between Paragraphs Using Same Style", "Espaço entre parágrafos com o mesmo estilo"),
("Ignore", "Ignorar"),
("Do Not Align to Baseline Grid", "Não alinhar à grade de linha de base"),
("Only Align First Line to Grid", "Alinhar somente a primeira linha à grade"),
("Balance Ragged Lines", "Equilibrar linhas irregulares"),
("Drop Caps and Nested Styles…", "Capitulares e estilos aninhados…"),
("GREP Styles…", "Estilos GREP…"),
("Restart/Continue Numbering", "Reiniciar/continuar numeração"),
("Convert Bullets and Numbering to Text", "Converter marcadores e numeração em texto"),
("Hyphenation…", "Hifenização…"),
("Hyphenation Settings", "Configurações de hifenização"),
("hyphens", "hifens"),
("Hyphenation Zone:", "Zona de hifenização:"),
("Better Spacing", "Melhor espaçamento"),
("Fewer Hyphens", "Menos hifens"),
("Hyphenate Capitalized Words", "Hifenizar palavras iniciadas por maiúscula"),
("Hyphenate Last Word", "Hifenizar última palavra"),
("Hyphenate Across Column", "Hifenizar entre colunas"),
("Character Formatting Controls", "Controles de formatação de caracteres"),
("Paragraph Formatting Controls", "Controles de formatação de parágrafos"),
("Font Size", "Tamanho da fonte"),
("Vertical Scale", "Escala vertical"),
("Horizontal Scale", "Escala horizontal"),
("Baseline Shift", "Deslocamento da linha de base"),
("Skew (False Italic)", "Inclinação (falso itálico)"),
("Left Indent", "Recuo à esquerda"),
("Right Indent", "Recuo à direita"),
("First Line Left Indent", "Recuo à esquerda da primeira linha"),
("Last Line Right Indent", "Recuo à direita da última linha"),
("Drop Cap Number of Lines", "Capitular: número de linhas"),
("Drop Cap One or More Characters", "Capitular: um ou mais caracteres"),
("Align to Baseline Grid", "Alinhar à grade de linha de base"),
("Apply Bullets", "Aplicar marcadores"),
("Apply Numbers", "Aplicar números"),
("Paste Vector Art", "Colar arte vetorial"),
("Baseline Options", "Opções de linha de base"),
("Fixed Number", "Número fixo"),
+37
View File
@@ -33,6 +33,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вырезать"),
("Copy", "Копировать"),
("Paste", "Вставить"),
("Restart Numbering", "Начать нумерацию заново"),
("Continue Numbering", "Продолжить нумерацию"),
("Keep Options…", "Параметры удержания…"),
("Space Between Paragraphs Using Same Style", "Интервал между абзацами одного стиля"),
("Ignore", "Игнорировать"),
("Do Not Align to Baseline Grid", "Не выравнивать по базовой сетке"),
("Only Align First Line to Grid", "Выравнивать по сетке только первую строку"),
("Balance Ragged Lines", "Сбалансировать рваные строки"),
("Drop Caps and Nested Styles…", "Буквицы и вложенные стили…"),
("GREP Styles…", "Стили GREP…"),
("Restart/Continue Numbering", "Начать заново/продолжить нумерацию"),
("Convert Bullets and Numbering to Text", "Преобразовать маркеры и нумерацию в текст"),
("Hyphenation…", "Расстановка переносов…"),
("Hyphenation Settings", "Параметры расстановки переносов"),
("hyphens", "дефисов"),
("Hyphenation Zone:", "Зона переносов:"),
("Better Spacing", "Лучшие интервалы"),
("Fewer Hyphens", "Меньше переносов"),
("Hyphenate Capitalized Words", "Переносить слова с прописной буквы"),
("Hyphenate Last Word", "Переносить последнее слово"),
("Hyphenate Across Column", "Переносить между колонками"),
("Character Formatting Controls", "Элементы управления форматированием символов"),
("Paragraph Formatting Controls", "Элементы управления форматированием абзацев"),
("Font Size", "Кегль"),
("Vertical Scale", "Масштаб по вертикали"),
("Horizontal Scale", "Масштаб по горизонтали"),
("Baseline Shift", "Смещение базовой линии"),
("Skew (False Italic)", "Наклон (ложный курсив)"),
("Left Indent", "Отступ слева"),
("Right Indent", "Отступ справа"),
("First Line Left Indent", "Отступ первой строки слева"),
("Last Line Right Indent", "Отступ последней строки справа"),
("Drop Cap Number of Lines", "Буквица: число строк"),
("Drop Cap One or More Characters", "Буквица: один или несколько символов"),
("Align to Baseline Grid", "Выровнять по сетке базовых линий"),
("Apply Bullets", "Применить маркеры"),
("Apply Numbers", "Применить нумерацию"),
("Clear", "Очистить"),
("Duplicate", "Дублировать"),
("Select All", "Выделить все"),
+37
View File
@@ -29,6 +29,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вирізати"),
("Copy", "Копіювати"),
("Paste", "Вставити"),
("Restart Numbering", "Почати нумерацію знову"),
("Continue Numbering", "Продовжити нумерацію"),
("Keep Options…", "Параметри утримання…"),
("Space Between Paragraphs Using Same Style", "Інтервал між абзацами одного стилю"),
("Ignore", "Ігнорувати"),
("Do Not Align to Baseline Grid", "Не вирівнювати за базовою сіткою"),
("Only Align First Line to Grid", "Вирівнювати за сіткою лише перший рядок"),
("Balance Ragged Lines", "Збалансувати нерівні рядки"),
("Drop Caps and Nested Styles…", "Буквиці та вкладені стилі…"),
("GREP Styles…", "Стилі GREP…"),
("Restart/Continue Numbering", "Почати знову/продовжити нумерацію"),
("Convert Bullets and Numbering to Text", "Перетворити маркери й нумерацію на текст"),
("Hyphenation…", "Перенесення…"),
("Hyphenation Settings", "Параметри перенесення"),
("hyphens", "дефісів"),
("Hyphenation Zone:", "Зона перенесення:"),
("Better Spacing", "Кращі інтервали"),
("Fewer Hyphens", "Менше перенесень"),
("Hyphenate Capitalized Words", "Переносити слова з великої літери"),
("Hyphenate Last Word", "Переносити останнє слово"),
("Hyphenate Across Column", "Переносити між колонками"),
("Character Formatting Controls", "Елементи керування форматуванням символів"),
("Paragraph Formatting Controls", "Елементи керування форматуванням абзаців"),
("Font Size", "Кегль"),
("Vertical Scale", "Масштаб по вертикалі"),
("Horizontal Scale", "Масштаб по горизонталі"),
("Baseline Shift", "Зсув базової лінії"),
("Skew (False Italic)", "Нахил (несправжній курсив)"),
("Left Indent", "Відступ ліворуч"),
("Right Indent", "Відступ праворуч"),
("First Line Left Indent", "Відступ першого рядка ліворуч"),
("Last Line Right Indent", "Відступ останнього рядка праворуч"),
("Drop Cap Number of Lines", "Буквиця: кількість рядків"),
("Drop Cap One or More Characters", "Буквиця: один або кілька символів"),
("Align to Baseline Grid", "Вирівняти за сіткою базових ліній"),
("Apply Bullets", "Застосувати маркери"),
("Apply Numbers", "Застосувати нумерацію"),
("Paste Vector Art", "Вставити векторну графіку"),
("Baseline Options", "Параметри базової лінії"),
("Fixed Number", "Фіксована кількість"),
+100
View File
@@ -798,6 +798,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
pen.line(&[(3.0, v), (5.0, v)]);
}
}
// Do Not Align to Baseline Grid: ragged lines of text, free of any grid.
"grid-none" => {
for (y, x1) in [(5.0, 17.0), (8.5, 12.0), (12.0, 17.0), (15.5, 14.0)] {
pen.line(&[(3.0, y), (x1, y)]);
}
}
"grid-baseline" => {
pen.rect(3.0, 3.0, 17.0, 17.0);
for y in [6.5, 10.0, 13.5] {
@@ -938,6 +944,86 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
pen.line(&[(15.0, 13.5), (17.0, 15.5), (15.0, 17.5)]);
}
}
"vscale" => {
pen.text(13.0, 10.5, "T", 15.0);
pen.line(&[(4.0, 3.0), (4.0, 17.0)]);
pen.line(&[(2.5, 5.0), (4.0, 3.0), (5.5, 5.0)]);
pen.line(&[(2.5, 15.0), (4.0, 17.0), (5.5, 15.0)]);
}
"hscale" => {
pen.text(10.0, 8.0, "T", 13.0);
pen.line(&[(3.0, 16.5), (17.0, 16.5)]);
pen.line(&[(5.0, 14.5), (3.0, 16.5), (5.0, 18.5)]);
pen.line(&[(15.0, 14.5), (17.0, 16.5), (15.0, 18.5)]);
}
"baseline-shift" => {
pen.text(7.0, 11.0, "A", 13.0);
pen.text(15.5, 5.5, "a", 8.0);
pen.line(&[(15.5, 17.5), (15.5, 10.0)]);
pen.line(&[(13.5, 12.0), (15.5, 10.0), (17.5, 12.0)]);
}
"skew" => {
pen.w = 1.8;
pen.line(&[(7.0, 4.0), (17.0, 4.0)]);
pen.line(&[(12.0, 4.0), (8.0, 17.0)]);
}
"indent-left" | "indent-right" | "indent-first" | "indent-last" => {
// Text lines, and an arrow at the side (or line) indented.
let (x0, x1) = match name {
"indent-left" => (9.0, 18.0),
"indent-right" => (2.0, 11.0),
_ => (2.0, 18.0),
};
for (k, y) in [4.0, 8.0, 12.0, 16.0].into_iter().enumerate() {
let (a, z) = match (name, k) {
("indent-first", 0) => (9.0, x1),
("indent-last", 3) => (x0, 11.0),
_ => (x0, x1),
};
pen.line(&[(a, y), (z, y)]);
}
let (y, from, to) = match name {
"indent-left" => (10.0, 2.0, 7.0),
"indent-right" => (10.0, 18.0, 13.0),
"indent-first" => (4.0, 2.0, 7.0),
_ => (16.0, 18.0, 13.0),
};
pen.line(&[(from, y), (to, y)]);
let d = if to > from { -2.0 } else { 2.0 };
pen.line(&[(to + d, y - 2.0), (to, y), (to + d, y + 2.0)]);
}
"space-before" | "space-after" => {
let before = name == "space-before";
let (bar, lines) = if before { (3.0, [11.0, 14.5, 18.0]) } else { (17.0, [2.0, 5.5, 9.0]) };
pen.line(&[(2.0, bar), (18.0, bar)]);
for y in lines {
pen.line(&[(8.0, y), (18.0, y)]);
}
let (a, z) = if before { (5.0, 10.0) } else { (10.0, 15.0) };
pen.line(&[(4.0, a), (4.0, z)]);
pen.line(&[(2.5, a + 1.5), (4.0, a), (5.5, a + 1.5)]);
pen.line(&[(2.5, z - 1.5), (4.0, z), (5.5, z - 1.5)]);
}
// Space Between Paragraphs Using Same Style: two paragraphs and the space between them.
"space-same" => {
for y in [2.0, 5.0, 15.0, 18.0] {
pen.line(&[(8.0, y), (18.0, y)]);
}
pen.line(&[(4.0, 6.5), (4.0, 13.5)]);
pen.line(&[(2.5, 8.0), (4.0, 6.5), (5.5, 8.0)]);
pen.line(&[(2.5, 12.0), (4.0, 13.5), (5.5, 12.0)]);
}
"drop-lines" | "drop-chars" => {
pen.text(5.5, 8.0, "A", 14.0);
if name == "drop-chars" {
pen.text(13.5, 4.5, "a", 8.0);
}
for (y, x) in [(if name == "drop-chars" { 8.5 } else { 3.5 }, 11.0), (8.5, 11.0), (13.5, 11.0), (17.5, 2.0)] {
if name == "drop-lines" || y > 5.0 {
pen.line(&[(x, y), (18.0, y)]);
}
}
}
"corner" => {
pen.w = 1.0;
for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] {
@@ -975,6 +1061,20 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
pen.line(&[(7.5, *y), (if k == 1 { 14.0 } else { 17.0 }, *y)]);
}
}
// Paragraph shading: a square with a checked fill (the Control panel's Shading).
"para-shading" => {
pen.w = 1.0;
pen.rect(4.0, 4.0, 16.0, 16.0);
pen.frect(4.0, 4.0, 10.0, 10.0);
pen.frect(10.0, 10.0, 16.0, 16.0);
}
// Paragraph border: a box with its corner drawn heavy (the Control panel's Border).
"para-border" => {
pen.w = 1.0;
pen.rect(4.0, 4.0, 16.0, 16.0);
pen.w = 2.5;
pen.line(&[(5.0, 12.0), (5.0, 5.0), (12.0, 5.0)]);
}
"pilcrow-menu" => {
pen.text(9.0, 10.0, "", 14.0);
pen.fill(&[(14.0, 15.0), (17.0, 15.0), (15.5, 17.0)]);
+44 -2
View File
@@ -114,7 +114,21 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
"{tab?: border|shading} — border and shading of the selected paragraphs (type.para)",
),
("app.spanColumnsDialog", "Span Columns…", None, "{} — span or split columns of the selected paragraphs (type.para)"),
("app.keepOptionsDialog", "Keep Options…", None, "{} — Keep with Next and Keep Lines Together of the selected paragraphs (type.para)"),
(
"app.nestedStylesDialog",
"Drop Caps and Nested Styles…",
None,
"{} — drop cap, nested styles and nested line styles of the selected paragraphs (type.para)",
),
("app.grepStylesDialog", "GREP Styles…", None, "{} — GREP styles of the selected paragraphs (type.para)"),
("app.bulletsNumberingDialog", "Bullets and Numbering…", None, "{} — bullets or numbers of the selected paragraphs (type.para)"),
(
"app.hyphenationDialog",
"Hyphenation…",
None,
"{} — Hyphenation Settings of the selected paragraphs (type.para): limits, zone, Better Spacing ↔ Fewer Hyphens, Preview",
),
(
"app.justificationDialog",
"Justification…",
@@ -510,6 +524,9 @@ pub const MENUS: &[(&str, &[&str])] = &[
"<",
"-",
">Bulleted & Numbered Lists",
"cmd:list.bullets",
"cmd:list.numbers",
"-",
"cmd:list.define",
"<",
"-",
@@ -1280,11 +1297,19 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphRules", json!({"rule": rule, "current": current})));
Ok(Value::Null)
}
"app.paragraphBordersDialog" | "app.spanColumnsDialog" | "app.bulletsNumberingDialog" => {
"app.paragraphBordersDialog"
| "app.spanColumnsDialog"
| "app.bulletsNumberingDialog"
| "app.keepOptionsDialog"
| "app.nestedStylesDialog"
| "app.grepStylesDialog" => {
let Some(a) = crate::panels::text_attrs(app) else { return Some(Err("select text or a text frame".into())) };
let section = match id {
"app.spanColumnsDialog" => "span",
"app.bulletsNumberingDialog" => "bullets",
"app.keepOptionsDialog" => "keep",
"app.nestedStylesDialog" => "nested",
"app.grepStylesDialog" => "grep",
_ if p.get("tab").and_then(Value::as_str) == Some("shading") => "shading",
_ => "border",
};
@@ -1297,6 +1322,18 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphLocal", fields));
Ok(Value::Null)
}
"app.hyphenationDialog" => {
let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else {
return Some(Err("select text or a text frame".into()));
};
let mixed: Vec<&str> = a["paraMixed"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
let current: serde_json::Map<String, Value> = crate::dialogs::HYPH_ATTRS
.iter()
.map(|k| (k.to_string(), if mixed.contains(k) { Value::Null } else { a["para"][*k].clone() }))
.collect();
app.ui.dialog = Some(crate::dialogs::Dialog::new("hyphenation", json!({"current": current})));
Ok(Value::Null)
}
"app.justificationDialog" => {
let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else {
return Some(Err("select text or a text frame".into()));
@@ -2399,6 +2436,11 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> {
"view.hiddenCharacters" => app.ui.hidden_characters,
"view.taggedFrames" => app.ui.tagged_frames,
"changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes),
// Pressed, as the Bulleted and Numbered List buttons: every paragraph the command applies
// to (selected text, cells or text frames) has it.
"list.bullets" | "list.numbers" => {
app.session.selection_has_list(if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers })
}
"window.controlBar" => app.ui.control_bar,
"window.split" => app.split,
"window.newWindow" => app.second_window,
@@ -2455,7 +2497,7 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
if is_window_control(id) {
return window_control_available(app, id);
}
if id == "app.justificationDialog" {
if matches!(id, "app.justificationDialog" | "app.hyphenationDialog") {
return enabled(app, "type.para");
}
if id.starts_with("fit.") {
+624 -156
View File
@@ -1348,21 +1348,141 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
}) {
let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
}
// Language: hyphenation, spelling and typographer's quotes follow it.
ui.add_space(1.0);
// ••• shows the rest in place, as InDesign's Properties panel does.
let more = temp_bool(ui, "props_char_more", false);
if more {
character_more(app, ui, &c, fw);
}
if more_options(ui, &app.ui.language).clicked() {
set_temp_bool(ui, "props_char_more", !more);
}
}
/// A percentage or other number field in one half of a two-field row, with its icon.
#[allow(clippy::too_many_arguments)]
fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option<f64> {
half_field_or_clear(ui, b, right, y, half, icon, tip, field).flatten()
}
/// [`half_field`] for a value that may be unset: emptying the field commits `Some(None)`.
#[allow(clippy::too_many_arguments)]
fn half_field_or_clear(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option<Option<f64>> {
let t = Tokens::get(ui.ctx());
let x = if right { half + 10.0 } else { 0.0 };
let ir = sub(b, x, y + 1.0, 19.0, 19.0);
icons::paint(ui.painter(), ir, icon, t.icon);
let resp = ui.interact(ir, ui.id().with(("half_icon", icon)), Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip));
let label = resp.id;
resp.on_hover_text(tip);
place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).labelled_by(label).show_or_clear(ui))
}
/// Properties › Character after •••: vertical and horizontal scale, baseline shift and skew,
/// language and OpenType, then All Caps, Small Caps, Superscript, Subscript, Underline and
/// Strikethrough, as InDesign lays them out.
fn character_more(app: &mut DesignApp, ui: &mut Ui, c: &Value, fw: f32) {
let lang_ui = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string();
let half = (fw - 10.0) / 2.0;
let b = block(ui, 2.0 * FIELD_H + 5.0);
const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0];
let pct = |v: &Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0);
let field = |id, v, suffix, decimals| NumField::number(id, v, suffix, decimals).spinner();
if let Some(v) = half_field(
ui,
b,
false,
0.0,
half,
"vscale",
&tr("Vertical Scale"),
field("cvscale", pct(&c["vScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0),
) {
let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}}));
}
if let Some(v) = half_field(
ui,
b,
true,
0.0,
half,
"hscale",
&tr("Horizontal Scale"),
field("chscale", pct(&c["hScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0),
) {
let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}}));
}
if let Some(v) = half_field(
ui,
b,
false,
26.0,
half,
"baseline-shift",
&tr("Baseline Shift"),
field("cbshift", c["baselineShift"].as_f64(), " pt", 2).range(-5000.0, 5000.0),
) {
let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}}));
}
if let Some(v) =
half_field(ui, b, true, 26.0, half, "skew", &tr("Skew (False Italic)"), field("cskew", c["skew"].as_f64(), "°", 1).range(-85.0, 85.0))
{
let _ = app.run("type.char", json!({"attrs": {"skew": v}}));
}
// Language (hyphenation, spelling and typographer's quotes follow it) and OpenType.
ui.add_space(4.0);
let lang = c["language"].as_str().unwrap_or("English: USA").to_string();
let label = crate::i18n::tr(&app.ui.language, "Language");
let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &lang), fw).on_hover_ui(|ui| {
crate::rtl::label(ui, label);
let label = tr("Language");
let row = block(ui, FIELD_H);
let resp = place(ui, sub(row, 0.0, 0.0, half, FIELD_H), |ui| widgets::dropdown(ui, crate::i18n::tr(&lang_ui, &lang), half)).on_hover_ui(|ui| {
crate::rtl::label(ui, label.clone());
});
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, &label));
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(fw);
ui.set_min_width(half);
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang));
});
if more_options(ui, &app.ui.language).clicked() {
show_panel(app, "character");
if place(ui, sub(row, half + 10.0, 0.0, half, FIELD_H), |ui| widgets::outline_button(ui, &tr("OpenType"), half)).clicked() {
show_typography_panel(app);
}
// The case and position buttons.
ui.add_space(8.0);
let caps = c["capitalization"].as_str().unwrap_or("normal").to_string();
let pos = c["position"].as_str().unwrap_or("normal").to_string();
let (ul, st) = (c["underline"].as_bool().unwrap_or(false), c["strikethrough"].as_bool().unwrap_or(false));
let buttons: [(&str, &str, bool, Value); 6] = [
("TT", "All Caps", caps == "allCaps", json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }})),
("Tᴛ", "Small Caps", caps == "smallCaps", json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }})),
("T¹", "Superscript", pos == "superscript", json!({"position": if pos == "superscript" { "normal" } else { "superscript" }})),
("T₁", "Subscript", pos == "subscript", json!({"position": if pos == "subscript" { "normal" } else { "subscript" }})),
("T̲", "Underline", ul, json!({"underline": !ul})),
("T", "Strikethrough", st, json!({"strikethrough": !st})),
];
let pitch = fw / 6.0;
let row = block(ui, 22.0);
for (i, (text, tip, on, attrs)) in buttons.into_iter().enumerate() {
let r = Rect::from_center_size(row.min + vec2(pitch * (i as f32 + 0.5), 11.0), vec2(26.0, 22.0));
let resp = ui.interact(r, ui.id().with(("case", i)), Sense::click());
let t = Tokens::get(ui.ctx());
if on {
ui.painter().rect_filled(r, 2.0, t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
let col = if on { t.text_strong } else { t.icon };
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col);
// Strikethrough: the T struck through (a combining stroke doesn't draw in every font).
if i == 5 {
ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col));
}
let tip = tr(tip);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip));
if resp.on_hover_text(&tip).clicked() {
let _ = app.run("type.char", json!({"attrs": attrs}));
}
}
ui.add_space(4.0);
}
/// Paragraph section: the 9 alignment buttons and •••.
@@ -1392,20 +1512,81 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) {
}
}
});
let more = temp_bool(ui, "props_para_more", false);
if more {
paragraph_more(app, ui, &a["para"], fw);
}
if more_options(ui, &app.ui.language).clicked() {
show_panel(app, "paragraph");
set_temp_bool(ui, "props_para_more", !more);
}
}
/// Properties › Paragraph after •••: left and right indent, first line and last line indent,
/// space before and after, drop cap lines and characters, then Hyphenate and Align to Baseline
/// Grid, as InDesign lays them out.
fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) {
let lang_ui = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string();
let u = units(app);
let half = (fw - 10.0) / 2.0;
ui.add_space(4.0);
let b = block(ui, 4.0 * FIELD_H + 15.0);
let rows = [
(("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")),
(("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")),
(("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")),
];
for (i, (l, r)) in rows.into_iter().enumerate() {
let y = i as f32 * (FIELD_H + 5.0);
for (right, (icon, tip, key)) in [(false, l), (true, r)] {
let min = para_field_min(key);
let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0);
if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) {
let _ = app.run("type.para", json!({"attrs": {key: v}}));
}
}
}
let y = 3.0 * (FIELD_H + 5.0);
for (right, icon, tip, key, max) in [
(false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES),
(true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS),
] {
let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64);
if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) {
let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}}));
}
}
ui.add_space(4.0);
ui.horizontal(|ui| {
// First Line Only is aligned too: the button shows it pressed, and a click turns alignment off.
let grid = matches!(p["gridAlign"].as_str(), Some("allLines" | "firstLineOnly"));
if widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() {
let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}}));
}
ui.add_space(12.0);
let mut h = p["hyphenate"].as_bool().unwrap_or(true);
if widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() {
let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
}
});
ui.add_space(4.0);
}
fn bullets_section(app: &mut DesignApp, ui: &mut Ui) {
let Some(a) = text_attrs(app) else { return };
let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string();
if text_attrs(app).is_none() {
return;
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
for (icon, kind, tip) in [("bullets", "bullets", "Bulleted List"), ("numbering", "numbers", "Numbered List")] {
let on = lt == kind;
// As InDesign's: a list with the default hanging indent, or (pressed: every selected
// paragraph has it, as Type › Apply Bullets shows) no list.
use designcraft_doc::ListType;
for (icon, kind, cmd, tip) in
[("bullets", ListType::Bullets, "list.bullets", "Bulleted List"), ("numbering", ListType::Numbers, "list.numbers", "Numbered List")]
{
let on = app.session.selection_has_list(kind);
if icon_toggle(ui, icon, on, crate::i18n::tr(&app.ui.language, tip)).clicked() {
let _ = app.run("type.para", json!({"attrs": {"listType": if on { "none" } else { kind }}}));
let _ = app.run(cmd, json!({"on": !on}));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -1505,7 +1686,7 @@ pub(crate) const LANGUAGES: &[&str] = &[
/// The Language list (Character panel, Properties ▸ Character): picking one sets the language of
/// the selected text.
fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) {
pub(crate) fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) {
for l in LANGUAGES {
if ui.selectable_label(*l == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
let _ = app.run("type.char", json!({"attrs": {"language": l}}));
@@ -1517,7 +1698,7 @@ fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) {
/// Kerning for the text selection, or at a caret for the pair at the caret: the label shows
/// Metrics, Optical, 0 or the manual value; its menu sets one of the first three or a manual value
/// in 1/1000 em (typed or stepped).
fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) {
pub(crate) fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) {
let kind = k["kind"].as_str().unwrap_or("metrics");
let manual = k["value"].as_f64().filter(|v| kind == "manual" && v.is_finite());
let label = match (kind, manual) {
@@ -1737,161 +1918,235 @@ pub fn show_typography_panel(app: &mut DesignApp) {
let _ = app.run("window.panel", json!({"panel": "typography"}));
}
/// The paragraph alignments in InDesign's order, with their icons.
const PARA_ALIGNS: [(Align, &str); 9] = [
(Align::Left, "palign-left"),
(Align::Center, "palign-center"),
(Align::Right, "palign-right"),
(Align::LeftJustified, "palign-justify-left"),
(Align::CenterJustified, "palign-justify-center"),
(Align::RightJustified, "palign-justify-right"),
(Align::FullyJustified, "palign-justify-all"),
(Align::TowardsSpine, "palign-spine-towards"),
(Align::AwayFromSpine, "palign-spine-away"),
];
/// Space Between Paragraphs Using Same Style's presets (pt), after Ignore.
const SAME_STYLE_SPACING: &[f64] = &[0.0, 3.0, 6.0, 9.0, 12.0, 18.0, 24.0];
/// The Paragraph panel, laid out as InDesign's: the alignment buttons in groups of 3, 4 and 2;
/// indents, spacing and drop cap fields by their icons in two columns; Shading and Border with
/// their colours; Hyphenate and the baseline grid buttons. The rest (composer, grid and ragged
/// lines options, the paragraph dialogs) is in the panel menu.
pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let Some(a) = text_attrs(app) else {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame."));
return;
};
let lang_ui = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string();
let u = units(app);
let p = a["para"].clone();
ui.horizontal(|ui| {
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
crate::menus::menu_button(ui, "☰", |ui| {
for (label, id) in [
("Paragraph Rules…", "app.paragraphRulesDialog"),
("Justification…", "app.justificationDialog"),
("Paragraph Borders and Shading…", "app.paragraphBordersDialog"),
("Span Columns…", "app.spanColumnsDialog"),
("Bullets and Numbering…", "app.bulletsNumberingDialog"),
] {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
let _ = app.run(id, json!({}));
ui.close();
}
}
});
});
});
let fw = full_width(ui);
let half = (fw - 10.0) / 2.0;
// Alignment, and the panel menu at the right.
let cur: Align = serde_json::from_value(p["align"].clone()).unwrap_or_default();
ui.horizontal(|ui| {
for (al, icon) in [
(Align::Left, "align-left"),
(Align::Center, "align-center"),
(Align::Right, "align-right"),
(Align::LeftJustified, "align-justify"),
(Align::FullyJustified, "align-justify-all"),
] {
if icons::button(ui, icon, 24.0, cur == al, al.label()).clicked() {
let _ = app.run("type.para", json!({"attrs": {"align": al}}));
}
const GAP: f32 = 8.0;
let pitch = ((fw - 24.0 - 2.0 * GAP) / 9.0).clamp(16.0, 24.0);
let b = block(ui, 21.0);
let mut x = 0.0;
for (i, (al, icon)) in PARA_ALIGNS.into_iter().enumerate() {
if i == 3 || i == 7 {
x += GAP;
}
});
// Paragraph Direction (World-Ready): switching also mirrors left/right alignment.
let rtl = p["direction"] == "rightToLeft";
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Direction"));
for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] {
if ui.selectable_label(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() && !on {
let align = match cur {
Align::Left => Align::Right,
Align::Right => Align::Left,
a => a,
};
let _ = app.run("type.para", json!({"attrs": {"direction": dir, "align": align}}));
}
}
});
egui::Grid::new("paragrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
for (row, ((la, ka), (lb, kb))) in [
(("Left", "leftIndent"), ("Right", "rightIndent")),
(("First", "firstLineIndent"), ("Last", "lastLineIndent")),
(("Before", "spaceBefore"), ("After", "spaceAfter")),
]
.into_iter()
.enumerate()
if place(ui, sub(b, x, 0.0, pitch, 21.0), |ui| widgets::icon_toggle_sized(ui, icon, cur == al, &tr(al.label()), vec2(pitch, 21.0))).clicked()
{
caption(ui, crate::i18n::tr(&app.ui.language, la));
if let Some(v) = measure(ui, &format!("pp{row}a"), p[ka].as_f64(), u, 64.0) {
let _ = app.run("type.para", json!({"attrs": {ka: v}}));
}
caption(ui, crate::i18n::tr(&app.ui.language, lb));
if let Some(v) = measure(ui, &format!("pp{row}b"), p[kb].as_f64(), u, 64.0) {
let _ = app.run("type.para", json!({"attrs": {kb: v}}));
}
ui.end_row();
let _ = app.run("type.para", json!({"attrs": {"align": al}}));
}
// Drop cap: number of lines and characters.
for (label, key, max, id) in [
("Drop Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES, "ppdl"),
("Drop Chars", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS, "ppdc"),
] {
caption(ui, crate::i18n::tr(&app.ui.language, label));
if let Some(v) = number(ui, id, p[key].as_f64(), "", 64.0, 0) {
let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}}));
}
}
ui.end_row();
});
ui.horizontal(|ui| {
let mut h = p["hyphenate"].as_bool().unwrap_or(true);
if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() {
let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
}
let mut grid = p["gridAlign"].as_str() == Some("allLines");
if ui.checkbox(&mut grid, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Align to baseline grid"))).changed() {
let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "allLines" } else { "none" }}}));
}
});
let mut optical = p["opticalMargin"].as_bool().unwrap_or(false);
if ui.checkbox(&mut optical, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Optical Margin Alignment"))).changed() {
let _ = app.run("type.para", json!({"attrs": {"opticalMargin": optical}}));
x += pitch;
}
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Composer"));
let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str());
if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph"))).clicked() {
let _ = app.run("type.para", json!({"attrs": {"composer": paragraph}}));
place(ui, sub(b, fw - 20.0, 0.0, 20.0, 21.0), |ui| crate::menus::menu_button(ui, "☰", |ui| paragraph_panel_menu(app, ui, &p)));
ui.add_space(6.0);
// Indents, spacing and drop cap, two to a row as in InDesign.
let row = FIELD_H + 5.0;
let b = block(ui, 5.0 * row - 5.0);
let rows = [
(("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")),
(("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")),
(("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")),
];
for (i, (l, r)) in rows.into_iter().enumerate() {
for (right, (icon, tip, key)) in [(false, l), (true, r)] {
// The first and last line indents may be negative (a hanging line), as InDesign's.
let min = if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 };
let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0);
if let Some(v) = half_field(ui, b, right, i as f32 * row, half, icon, &tr(tip), field) {
let _ = app.run("type.para", json!({"attrs": {key: v}}));
}
}
if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line"))).clicked() {
let _ = app.run("type.para", json!({"attrs": {"composer": single_line}}));
}
// Space Between Paragraphs Using Same Style: Ignore (unset) or a space that replaces the
// space after and before between paragraphs of the same style.
let ignore = tr("Ignore");
let field = NumField::measure("sameParaStyleSpacing", p["sameParaStyleSpacing"].as_f64(), u)
.spinner()
.presets(SAME_STYLE_SPACING)
.empty_text(&ignore)
.range(0.0, 10_000.0);
if let Some(v) = half_field_or_clear(ui, b, false, 3.0 * row, half, "space-same", &tr("Space Between Paragraphs Using Same Style"), field) {
let _ = app.run("type.para", json!({"attrs": {"sameParaStyleSpacing": v}}));
}
for (right, icon, tip, key, max) in [
(false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES),
(true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS),
] {
let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64);
if let Some(v) = half_field(ui, b, right, 4.0 * row, half, icon, &tr(tip), field) {
let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}}));
}
});
// Paragraph Border and Shading.
let swatches: Vec<String> =
app.session.active().map(|d| d.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default();
for (on_key, color_key, label) in [("shadingOn", "shadingColor", "Shading"), ("borderOn", "borderColor", "Border")] {
ui.horizontal(|ui| {
let mut on = p[on_key].as_bool().unwrap_or(false);
if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() {
let _ = app.run("type.para", json!({ on_key: on }));
}
if on {
let cur = p[color_key].as_str().unwrap_or("[Black]").to_string();
egui::ComboBox::from_id_salt(("pbs", label)).selected_text(&cur).width(110.0).show_ui(ui, |ui| {
for w in &swatches {
if ui.selectable_label(*w == cur, w).clicked() {
let _ = app.run("type.para", json!({ color_key: w }));
}
}
});
if label == "Shading" {
if let Some(v) = number(ui, "pshtint", p["shadingTint"].as_f64().map(|t| t * 100.0), "%", 46.0, 0) {
let _ = app.run("type.para", json!({"shadingTint": (v / 100.0).clamp(0.0, 1.0)}));
}
} else if let Some(v) = number(ui, "pbw", p["borderWeights"][0].as_f64(), " pt", 50.0, 2) {
let w = [v.max(0.0); 4];
let _ = app.run("type.para", json!({ "borderWeights": w }));
}
}
});
}
if p["shadingOn"].as_bool().unwrap_or(false) || p["borderOn"].as_bool().unwrap_or(false) {
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Offsets"));
let o = p["borderOffsets"][0].as_f64().or(p["shadingOffsets"][0].as_f64());
if let Some(v) = number(ui, "pbo", o, " pt", 50.0, 2) {
let o = [v; 4];
let _ = app.run("type.para", json!({ "borderOffsets": o, "shadingOffsets": o }));
// Shading and Border: on or off at the left; at the right the Paragraph Borders and Shading
// dialog on its tab and the colour.
ui.add_space(10.0);
let doc = app.session.active().map(|d| d.doc.clone());
let b = block(ui, 2.0 * row - 5.0);
for (i, (on_key, color_key, label, icon, tab, name)) in [
("shadingOn", "shadingColor", "Shading", "para-shading", "shading", "Paragraph Shading"),
("borderOn", "borderColor", "Border", "para-border", "border", "Paragraph Border"),
]
.into_iter()
.enumerate()
{
let y = i as f32 * row;
let mut on = p[on_key].as_bool().unwrap_or(false);
if place(ui, sub(b, 0.0, y, half, FIELD_H), |ui| widgets::checkbox(ui, &mut on, &tr(label))).changed() {
let _ = app.run("type.para", json!({"attrs": {on_key: on}}));
}
if place(ui, sub(b, half + 10.0, y, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…")))
.clicked()
{
let _ = app.run("app.paragraphBordersDialog", json!({"tab": tab}));
}
if let Some(doc) = &doc {
let color = p[color_key].as_str().unwrap_or("[Black]").to_string();
let w = half - 24.0;
if let Some(sw) = place(ui, sub(b, half + 34.0, y, w, FIELD_H), |ui| crate::chrome::swatch_field(ui, doc, &color, w, &tr(name))) {
let _ = app.run("type.para", json!({"attrs": {color_key: sw}}));
}
});
}
}
// Hyphenate, and Do Not Align / Align to Baseline Grid.
ui.add_space(10.0);
let b = block(ui, FIELD_H);
let mut h = p["hyphenate"].as_bool().unwrap_or(true);
if place(ui, sub(b, 0.0, 0.0, half, FIELD_H), |ui| widgets::checkbox(ui, &mut h, &tr("Hyphenate"))).changed() {
let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
}
let grid = matches!(p["gridAlign"].as_str(), Some("allLines" | "firstLineOnly"));
for (i, (icon, tip, on, v)) in
[("grid-none", "Do Not Align to Baseline Grid", !grid, "none"), ("grid-baseline", "Align to Baseline Grid", grid, "allLines")]
.into_iter()
.enumerate()
{
if place(ui, sub(b, fw - 48.0 + i as f32 * 25.0, 0.0, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, on, &tr(tip))).clicked() && !on {
let _ = app.run("type.para", json!({"attrs": {"gridAlign": v}}));
}
}
ui.add_space(4.0);
}
/// The Paragraph panel menu, in the order of InDesign's.
fn paragraph_panel_menu(app: &mut DesignApp, ui: &mut Ui, p: &Value) {
let lang = app.ui.language.clone();
let item = |ui: &mut Ui, label: &str, check: Option<bool>, enabled: bool| -> bool {
let label = crate::i18n::tr(&lang, label);
let text = match check {
Some(true) => format!("✓ {label}"),
Some(false) => format!(" {label}"),
None => label.to_string(),
};
let clicked = ui.add_enabled(enabled, egui::Button::new(crate::rtl::widget(ui, text))).clicked();
if clicked {
ui.close();
}
clicked
};
let mut attrs = None;
let mut command = None;
let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str());
if item(ui, "Paragraph Composer", Some(!single), true) {
attrs = Some(json!({"composer": paragraph}));
}
if item(ui, "Single-line Composer", Some(single), true) {
attrs = Some(json!({"composer": single_line}));
}
ui.separator();
let grid = p["gridAlign"].as_str().unwrap_or("none");
if item(ui, "Only Align First Line to Grid", Some(grid == "firstLineOnly"), grid != "none") {
attrs = Some(json!({"gridAlign": if grid == "firstLineOnly" { "allLines" } else { "firstLineOnly" }}));
}
let balance = p["balanceRagged"].as_bool().unwrap_or(false);
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}));
}
ui.separator();
for (label, id) in [
("Justification…", "app.justificationDialog"),
("Keep Options…", "app.keepOptionsDialog"),
("Span Columns…", "app.spanColumnsDialog"),
("Hyphenation…", "app.hyphenationDialog"),
("Drop Caps and Nested Styles…", "app.nestedStylesDialog"),
("GREP Styles…", "app.grepStylesDialog"),
("Paragraph Rules…", "app.paragraphRulesDialog"),
("Paragraph Borders and Shading…", "app.paragraphBordersDialog"),
] {
if item(ui, label, None, true) {
command = Some(id);
}
}
ui.separator();
if item(ui, "Bullets and Numbering…", None, true) {
command = Some("app.bulletsNumberingDialog");
}
// Restart Numbering at 1, or Continue Numbering from the paragraph before, for a numbered list.
let numbered = p["listType"].as_str() == Some("numbers");
let restarts = p["startAt"].is_u64();
let label = match (numbered, restarts) {
(false, _) => "Restart/Continue Numbering",
(true, false) => "Restart Numbering",
(true, true) => "Continue Numbering",
};
if item(ui, label, None, numbered) {
attrs = Some(json!({"startAt": if restarts { Value::Null } else { json!(1) }}));
}
item(ui, "Convert Bullets and Numbering to Text", None, false);
if item(ui, "Define Lists…", None, true) {
command = Some("list.define");
}
// Paragraph Direction (World-Ready): switching also mirrors left and right alignment.
ui.separator();
let rtl = p["direction"] == "rightToLeft";
for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] {
if item(ui, label, Some(on), true) && !on {
let align = match serde_json::from_value(p["align"].clone()).unwrap_or_default() {
Align::Left => Align::Right,
Align::Right => Align::Left,
a => a,
};
attrs = Some(json!({"direction": dir, "align": align}));
}
}
if let Some(attrs) = attrs {
let _ = app.run("type.para", json!({"attrs": attrs}));
}
// As a menu choice: Define Lists… and the other commands open their dialogs.
if let Some(id) = command {
crate::menus::activate(app, id, &Value::Null);
}
let ps = a["paragraphStyle"].as_str().unwrap_or("").to_string();
let ov = a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0);
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Style"));
super::para_style_picker(app, ui, &ps, ov > 0, 190.0);
});
}
/// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour.
@@ -2905,9 +3160,18 @@ pub fn digit_label(lang: &str, o: (&str, &str, &str)) -> String {
if o.2.is_empty() { name.to_string() } else { format!("{name} {}", o.2) }
}
/// The least value a paragraph field takes: the first and last line indents may be negative (a
/// hanging line, a last line past the right indent), as in InDesign; the others not.
pub(crate) fn para_field_min(key: &str) -> f64 {
if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }
}
#[cfg(test)]
mod tests {
use egui_kittest::{Harness, kittest::Queryable};
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
use super::*;
@@ -2979,6 +3243,18 @@ mod tests {
}
/// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel.
/// The first and last line indents take negative values, as InDesign's (and the Paragraph
/// panel's) do; the left and right indents and the spacing don't.
#[test]
fn last_line_indent_may_be_negative() {
for key in ["firstLineIndent", "lastLineIndent"] {
assert!(super::para_field_min(key) < 0.0, "{key}");
}
for key in ["leftIndent", "rightIndent", "spaceBefore", "spaceAfter"] {
assert_eq!(super::para_field_min(key), 0.0, "{key}");
}
}
#[test]
fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -3094,6 +3370,9 @@ mod tests {
app,
);
h.run_steps(3);
assert!(h.query_by_label("Language").is_none(), "under •••, as in InDesign");
h.get_all_by_label("More Options").next().unwrap().click();
h.run_steps(3);
h.get_by_label("Language").click();
h.run_steps(2);
h.get_by_label("French").click();
@@ -3101,6 +3380,166 @@ mod tests {
assert_eq!(language(h.state_mut()), "French");
}
/// Properties › Character and Paragraph •••, as in InDesign: the sections expand in place,
/// with the scales, baseline shift, skew, language, OpenType and the case buttons, and the
/// indents, spacing and drop caps.
/// Properties › Paragraph ••• on a First Line Only paragraph: Align to Baseline Grid shows
/// pressed, and a click turns alignment off instead of making it All Lines.
#[test]
fn more_options_grid_button_keeps_first_line_only() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "If fine plants", "caret": true})).unwrap();
app.run("edit.selectAll", json!({})).unwrap();
app.run("type.para", json!({"attrs": {"gridAlign": "firstLineOnly"}})).unwrap();
let mut h = panel_harness(app, show);
h.run_steps(3);
h.get_all_by_label("More Options").nth(1).unwrap().click();
h.run_steps(3);
assert_eq!(h.get_by_label("Align to Baseline Grid").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True));
assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["gridAlign"], "firstLineOnly");
h.get_by_label("Align to Baseline Grid").click();
h.run_steps(3);
assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["gridAlign"], "none");
}
#[test]
fn more_options_expand_character_and_paragraph_in_place() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "If fine plants", "caret": true})).unwrap();
app.run("edit.selectAll", json!({})).unwrap();
let mut h = panel_harness(app, show);
h.run_steps(3);
for l in ["Vertical Scale", "Small Caps", "Drop Cap Number of Lines", "Space Before"] {
assert!(h.query_by_label(l).is_none(), "{l} hidden until •••");
}
h.get_all_by_label("More Options").next().unwrap().click();
h.run_steps(3);
h.get_by_label("Small Caps").click();
h.run_steps(3);
let _ = h.get_by_label("OpenType");
let _ = h.get_by_label("Language");
h.get_all_by_label("More Options").nth(1).unwrap().click();
h.run_steps(3);
let _ = h.get_by_label("Hyphenate");
h.get_by_label("Align to Baseline Grid").click();
h.run_steps(3);
let a = text_attrs(h.state_mut()).unwrap();
assert_eq!(a["chars"]["capitalization"], "smallCaps");
assert_eq!(a["para"]["gridAlign"], "allLines");
// ••• again folds the section.
h.get_all_by_label("More Options").next().unwrap().click();
h.run_steps(3);
assert!(h.query_by_label("Small Caps").is_none());
}
/// The Paragraph panel on a First Line Only paragraph: Align to Baseline Grid shows pressed
/// and keeps First Line Only, and the panel menu checks Only Align First Line to Grid.
#[test]
fn paragraph_panel_keeps_first_line_only() {
let mut app = text_app();
app.run("type.para", json!({"attrs": {"gridAlign": "firstLineOnly"}})).unwrap();
let mut h = panel_harness(app, paragraph_panel);
h.run_steps(3);
assert_eq!(h.get_by_label("Align to Baseline Grid").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True));
h.get_by_label("Align to Baseline Grid").click();
h.run_steps(3);
assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["gridAlign"], "firstLineOnly");
h.get_by_label("☰").click();
h.run_steps(3);
let _ = h.get_by_label("✓ Only Align First Line to Grid");
}
#[test]
fn paragraph_panel_menu_define_lists_opens_its_dialog() {
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("Define Lists…").click();
h.run_steps(3);
assert!(h.state_mut().ui.dialog.is_some(), "Define Lists");
}
#[test]
fn paragraph_panel_is_laid_out_as_indesigns_with_its_panel_menu() {
let mut h = panel_harness(text_app(), paragraph_panel);
h.run_steps(3);
for l in [
"Left Indent",
"Right Indent",
"First Line Left Indent",
"Last Line Right Indent",
"Space Before",
"Space After",
"Space Between Paragraphs Using Same Style",
"Drop Cap Number of Lines",
"Drop Cap One or More Characters",
"Paragraph Shading",
"Paragraph Border",
"Hyphenate",
"Do Not Align to Baseline Grid",
] {
let _ = h.get_by_label(l);
}
let para = |h: &mut Harness<'static, DesignApp>| text_attrs(h.state_mut()).unwrap()["para"].clone();
// Space Between Paragraphs Using Same Style starts at Ignore; a value is set, and emptying
// the field ignores it again.
assert!(para(&mut h)["sameParaStyleSpacing"].is_null());
let field = h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap();
field.focus();
field.type_text("6 pt");
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(para(&mut h)["sameParaStyleSpacing"], 6.0);
h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap().focus();
h.run_steps(1);
for _ in 0..8 {
h.key_press(egui::Key::Backspace);
}
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert!(para(&mut h)["sameParaStyleSpacing"].is_null(), "Ignore");
// Shading on, then the grid buttons and Only Align First Line to Grid from the menu.
h.get_by_label("Shading").click();
h.run_steps(3);
assert_eq!(para(&mut h)["shadingOn"], true);
h.get_by_label("Align to Baseline Grid").click();
h.run_steps(3);
assert_eq!(para(&mut h)["gridAlign"], "allLines");
h.get_by_label("☰").click();
h.run_steps(3);
for l in [
" Balance Ragged Lines",
"Keep Options…",
"Drop Caps and Nested Styles…",
"GREP Styles…",
"Define Lists…",
"Restart/Continue Numbering",
] {
let _ = h.get_by_label(l);
}
h.get_by_label(" Only Align First Line to Grid").click();
h.run_steps(3);
assert_eq!(para(&mut h)["gridAlign"], "firstLineOnly");
h.get_by_label("Do Not Align to Baseline Grid").click();
h.run_steps(3);
assert_eq!(para(&mut h)["gridAlign"], "none");
// Keep Options… opens the selected paragraphs' Keep Options, and OK applies them.
h.get_by_label("☰").click();
h.run_steps(3);
h.get_by_label("Keep Options…").click();
h.run_steps(3);
let app = h.state_mut();
let d = app.ui.dialog.as_mut().expect("Keep Options");
assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "keep"));
d.fields.insert("p.keepWithNext".into(), json!(2));
crate::dialogs::confirm(app).unwrap();
assert_eq!(para(&mut h)["keepWithNext"], 2);
}
fn kerning(app: &mut DesignApp) -> Value {
text_attrs(app).unwrap()["chars"]["kerning"].clone()
}
@@ -3364,6 +3803,35 @@ mod tests {
h
}
/// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs
/// bullets with InDesign's hanging indent; pressed again, both go.
#[test]
fn the_bulleted_list_button_applies_a_hanging_indent() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let f = app
.run(
"frame.create",
json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs
1 cup milk"}),
)
.unwrap();
app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap();
let mut h = panel(app);
h.get_by_label("Bulleted List").click();
h.run_steps(3);
let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap();
assert_eq!(
(a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()),
(Some("bullets"), Some(18.0), Some(-18.0))
);
assert_eq!(crate::menus::checked(h.state(), "list.bullets", &json!({})), Some(true), "Type › Apply Bullets shows it");
h.get_by_label("Bulleted List").click();
h.run_steps(3);
let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap();
assert_eq!((a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64()), (Some("none"), Some(0.0)));
}
#[test]
fn several_shapes_show_pathfinder_and_intersect_combines_them() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+57 -4
View File
@@ -80,6 +80,10 @@ pub struct NumField<'a> {
range: (f64, f64),
enabled: bool,
parens: bool,
/// The widget naming the field (an icon or caption beside it), for accessibility.
label: Option<egui::Id>,
/// What an unset value shows (InDesign's "Ignore"), and the first of the presets.
empty: Option<&'a str>,
}
impl<'a> NumField<'a> {
@@ -90,7 +94,20 @@ impl<'a> NumField<'a> {
Self::new(id, value, NumKind::Number { suffix, decimals })
}
fn new(id: &'a str, value: Option<f64>, kind: NumKind<'a>) -> Self {
NumField { id, value, kind, width: 72.0, spinner: false, presets: &[], step: None, range: (f64::MIN, f64::MAX), enabled: true, parens: false }
NumField {
id,
value,
kind,
width: 72.0,
spinner: false,
presets: &[],
step: None,
range: (f64::MIN, f64::MAX),
enabled: true,
parens: false,
label: None,
empty: None,
}
}
/// Total width (spinner and preset segments included).
pub fn width(mut self, w: f32) -> Self {
@@ -130,6 +147,19 @@ impl<'a> NumField<'a> {
self.enabled = on;
self
}
/// The editor is labelled by widget `id` (an icon or caption that names the field), so
/// assistive technology reads the field's name with its value.
pub fn labelled_by(mut self, id: egui::Id) -> Self {
self.label = Some(id);
self
}
/// An unset value shows `text` (dimmed), and the preset menu offers it first: picking it, or
/// emptying the field, commits `Some(None)` from [`NumField::show_or_clear`].
pub fn empty_text(mut self, text: &'a str) -> Self {
self.empty = Some(text);
self
}
/// Draw the field; returns the new value when the user commits an edit (Enter, focus loss,
/// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset).
@@ -194,13 +224,16 @@ impl<'a> NumField<'a> {
// Value.
ui.painter().rect_filled(text_rect, 0.0, bg);
let mut buf: String = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| self.value.map(|v| self.fmt(v)).unwrap_or_default());
let te = egui::TextEdit::singleline(&mut buf)
let mut te = egui::TextEdit::singleline(&mut buf)
.id(focused_id)
.frame(egui::Frame::NONE)
.font(label_font())
.text_color(fg)
.margin(egui::Margin::ZERO)
.vertical_align(egui::Align::Center);
if let Some(empty) = self.empty {
te = te.hint_text(egui::RichText::new(empty).font(label_font()).color(t.text_dim));
}
// The field already allocated its slot. A scoped allocation here would consume another
// grid cell and enlarge toolbar rows; the value editor must stay inside that same slot.
let value_rect = Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0));
@@ -210,6 +243,10 @@ impl<'a> NumField<'a> {
editor_ui.disable();
}
let r = editor_ui.put(value_rect, te);
let r = match self.label {
Some(id) => r.labelled_by(id),
None => r,
};
if r.has_focus() {
// ↑/↓ step the value while typing.
let (up, down, shift) = ui.input_mut(|i| {
@@ -227,8 +264,14 @@ impl<'a> NumField<'a> {
ui.data_mut(|d| d.insert_temp(key, buf.clone()));
}
if r.lost_focus() {
if let Some(v) = self.parse(&buf).map(clamp)
&& self.value.is_none_or(|old| (old - v).abs() > 1e-9)
// An automatic value (in parentheses) typed over commits even when it's the same
// number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. Escape reverts
// whatever was typed, as in InDesign.
let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape));
let typed_over_auto = self.parens && !buf.trim_start().starts_with('(');
if escaped {
} else if let Some(v) = self.parse(&buf).map(clamp)
&& (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9))
{
out = Some(v);
} else if buf.trim().is_empty() && self.value.is_some() {
@@ -253,6 +296,12 @@ impl<'a> NumField<'a> {
if let Some(resp) = preset_resp {
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(self.width);
if let Some(empty) = self.empty
&& ui.selectable_label(self.value.is_none(), empty).clicked()
{
cleared = self.value.is_some();
ui.close();
}
for v in self.presets {
if ui.selectable_label(self.value.is_some_and(|c| (c - v).abs() < 1e-9), self.kind.format(*v)).clicked() {
out = Some(*v);
@@ -382,6 +431,8 @@ pub fn more_options(ui: &mut Ui, language: &str) -> Response {
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover());
let r = Rect::from_min_max(pos2(row.max.x - 26.0, row.min.y), pos2(row.max.x - 4.0, row.max.y));
let resp = ui.interact(r, ui.id().with(("more", row.min.y as i32)), Sense::click());
let label = crate::i18n::tr(language, "More Options");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
for i in 0..3 {
ui.painter().circle_filled(pos2(r.min.x + 4.0 + i as f32 * 7.0, r.center().y), 1.9, if resp.hovered() { t.text_strong } else { t.icon });
}
@@ -567,6 +618,8 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response {
*on = !*on;
resp.mark_changed();
}
let checked = *on;
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label));
resp
}
+3 -2
View File
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -382,7 +382,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel and Character Style Options; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default; IDML Kashidas): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel menu, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel and Character Style Options; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default; IDML Kashidas): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Preferences ▸ General ▸ Interface Language, `app.language`; each language listed by its own name): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage. Polish covers 1,717 catalog keys with the same category-first count captions and English fallback.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with the working CMYK space's profile (by default craft-cms's Coated FOGRA39 profile, identifier FOGRA39; Generic CMYK and Device CMYK embed our Generic CMYK profile, lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, `xmp:MetadataDate` and `xmpMM:VersionID` in XMP, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
@@ -409,6 +409,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Input methods (IME) in the Type tool: a Chinese, Japanese or Korean composition shows its marked text in place in the story, underlined (the clause being converted twice), with the IME's candidate window at the caret through zoom, scrolling, view rotation and vertical text; the commit is one `text.insert` and one undo step, a cancelled composition leaves the document as it was; keys, shortcuts, the clipboard and Undo wait while the IME composes, and a click or another command keeps the marked text as typed. The control channel's `ui.ime` and the MCP `ime` tool (headless too) send IME events. Not yet checked by hand with a system IME (macOS, Windows, Linux, web).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Bulleted and Numbered List buttons (Properties › Bullets and Numbering; Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers, ✓ when the selection has them; `list.bullets`, `list.numbers`): as InDesign's, a paragraph with no indents gets the default hanging indent (Left Indent 0.25 in, First Line Indent −0.25 in), so the bullet or number sits at the margin and the text after its tab at the indent; pressed again, the list goes and so does that indent if the button set it (`listHang`) and it is still as applied (a hanging indent the paragraph had before, or its style gives, stays). A selection ending right after a return leaves the next paragraph out. One undo step; values equal to the paragraph style's are left to the style.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.
- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.
+2 -2
View File
@@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated.
| Single-line Composer | greedy | greedy with the same ranges | ✓ | |
| World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested |
| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) |
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | |
| Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently |
| Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | |
@@ -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 and frame grids | ✓ | ✓ | |
| Baseline grid | document grid relative to the top of the page or each page's top margin; frame grids | ✓ | ✓ | |
| 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) |
+4 -3
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (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-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)
Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel,
against InDesign 2026. The look was measured from the running app
@@ -26,8 +26,8 @@ basics of editing fail for too many users on Windows and Linux.
| Area | InDesign 2026 | DesignCraft | Status | Evidence |
|---|---|---|---|---|
| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look | ✓ | 11-observed-ui.md |
| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143) | ✓ | |
| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls (¶ with the Bulleted and Numbered List buttons, Shading and Border), and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 |
| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143); ••• expands Character (scales, baseline shift, skew, language, case and position buttons) and Paragraph (indents, spacing, drop cap, baseline grid, hyphenation) in place | ✓ | Learn Skills Daily course, chapter 16; `properties.rs` `more_options_expand_character_and_paragraph_in_place` |
| Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows the selection tool used last (Selection or Direct Selection) for one gesture, #919 |
| Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) |
| Live corners (yellow handle) | ✓ | ✓ widget on a selected rectangle frame; diamonds drag the corner size (Shift: one corner), Alt-click steps the shape (Shift+Alt: all) | ✓ | #186 |
@@ -122,6 +122,7 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) |