Merge #81 (includes #83, #84) into fix/compose-baseline-frame-fit

#83's Keep Lines Together tests change expectation: with the last baseline
allowed on the bottom inset, the first frame holds one more line, so the
10-line paragraph splits 7/3 instead of 6/4 (the keep rule is unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-07 23:24:30 -04:00
co-authored by Claude Opus 5.5
59 changed files with 4804 additions and 492 deletions
Generated
+2
View File
@@ -857,6 +857,7 @@ dependencies = [
name = "designcraft"
version = "0.2.1"
dependencies = [
"arboard",
"designcraft-engine",
"designcraft-ui-egui",
"eframe",
@@ -911,6 +912,7 @@ dependencies = [
"regex",
"unicode-bidi",
"unicode-bidi-mirroring",
"unicode-segmentation",
]
[[package]]
+5 -3
View File
@@ -11,13 +11,13 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
**Working today**
- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend.
- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
@@ -35,6 +35,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
@@ -63,6 +64,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, DropCapStyle on import): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down to the baseline of line N (up to 25), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), baselines don't move; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps.
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption.
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
@@ -102,7 +104,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- 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 for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): 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, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; 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 (Edit ▸ Interface Language, `app.language`): German, French, Spanish, 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.
- 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 our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
+1
View File
@@ -23,6 +23,7 @@ winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
# The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs.
+1
View File
@@ -165,6 +165,7 @@ fn services() -> Services {
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())),
..Default::default()
}
}
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
unicode-bidi = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-bidi-mirroring = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
+443 -77
View File
@@ -36,9 +36,9 @@ use designcraft_geom::{Point, Rect};
use crate::breaker::{Break, Spacing};
pub use crate::cache::Cache;
use crate::notes::Notes;
pub use crate::notes::{find_note, hit_note, note_caret};
pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret};
use crate::shape::{Glyph, StyleTable, SubstCtx};
pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell};
pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell, hit_cell_with};
/// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]).
#[derive(Clone, Debug, PartialEq)]
@@ -176,6 +176,20 @@ pub struct Line {
pub hj: u8,
/// The paragraph's Keep Options couldn't be honoured here (Highlight Keep Violations).
pub keep_violation: bool,
/// The drop cap a paragraph's first line starts with (its first glyphs, set down to a later
/// line's baseline).
pub drop_cap: Option<DropCapBox>,
}
/// Where a drop cap sits (frame inner space).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DropCapBox {
/// Story bytes from its line's start up to here are the drop cap's.
pub end: usize,
/// Its baseline.
pub baseline: f64,
/// The area it covers: from its line's top down to the text below its baseline.
pub rect: Rect,
}
/// A paragraph rule or shading rectangle in frame inner space.
@@ -583,6 +597,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub);
}
let sub_objects = sub.objects.clone();
let drop_end = drop_cap_end(&story.text, prange.clone(), &pp, sub.vertical);
let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts };
let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type, glyph_fallback: doc.settings.glyph_fallback };
let mut sp = shape::shape_para(
@@ -598,7 +613,20 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&pp.nested_styles,
&pp.grep_styles,
&[],
(drop_end, &pp.drop_cap_style),
);
// Drop cap: the size and indent come from the text beside it (`base_*` when there's none).
let base_leading = match base_chars.leading {
designcraft_doc::Leading::Auto => base_chars.size * pp.auto_leading,
designcraft_doc::Leading::Points(v) => v,
};
let base_cap = {
let face = db.face(&base_chars.font_family, &base_chars.font_style);
face.cap_height * base_chars.size / face.upem * base_chars.v_scale
};
let drop_grid = cur_frame.and_then(|f| f.grid).filter(|_| pp.grid_align == GridAlign::AllLines).map(|g| g.1);
// Align Left Edge (left-to-right paragraphs).
let drop_align = pp.drop_cap_align_left && pp.direction == designcraft_doc::TextDirection::LeftToRight;
if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() {
// Nested line styles: find where the first lines end in this column, restyle them, and
// look again (the style changes the widths) until the lines settle.
@@ -607,9 +635,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let spacing = spacing_for(&pp, base_chars.size);
let mut lines: Vec<(std::ops::Range<usize>, String)> = Vec::new();
for _ in 0..3 {
let width = |j: usize| (col.width() - pp.left_indent - pp.right_indent - if j == 0 { pp.first_line_indent } else { 0.0 }).max(1.0);
// The same spacing, hyphenation and breaker as the layout below.
// The same drop cap, spacing, hyphenation and breaker as the layout below.
let mut gl = sp.glyphs.clone();
let dc = split_drop_cap(db, &mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align);
let width = |j: usize| {
let ind = match &dc {
Some(dc) if j < dc.lines => dc.indent(),
_ if j == 0 => pp.first_line_indent,
_ => 0.0,
};
(col.width() - pp.left_indent - pp.right_indent - ind).max(1.0)
};
apply_desired_spacing(&mut gl, &pp);
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
@@ -651,9 +687,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&pp.nested_styles,
&pp.grep_styles,
&lines,
(drop_end, &pp.drop_cap_style),
);
}
}
// Rules in Text Color: the colour of the paragraph's first character (rule above) and last
// character (rule below).
let text_color = |g: Option<&Glyph>| {
g.and_then(|g| table.styles.get(g.style as usize))
.map_or((base_chars.fill.clone(), base_chars.fill_tint), |s| (s.fill.clone(), s.fill_tint))
};
let (above, below) = (text_color(sp.glyphs.iter().find(|g| g.len > 0)), text_color(sp.glyphs.iter().rev().find(|g| g.len > 0)));
let rule_color = |r: &designcraft_doc::Rule, text: &(String, f32)| {
if r.color == designcraft_doc::TEXT_COLOR { text.clone() } else { (r.color.clone(), r.tint) }
};
let drop_cap = split_drop_cap(db, &mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align);
// List labels take the default super/subscript settings.
let label_env = shape::TypeEnv { adv: Default::default(), ..env };
match pp.list_type {
@@ -689,10 +737,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
apply_desired_spacing(&mut glyphs, &pp);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let base_size = base_chars.size;
let base_leading = match base_chars.leading {
designcraft_doc::Leading::Auto => base_size * pp.auto_leading,
designcraft_doc::Leading::Points(v) => v,
};
let spacing = spacing_for(&pp, base_size);
// Paragraph start options.
if force_col[pi] {
@@ -717,6 +761,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut col_first_line = 0usize;
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;
// The drop cap's line (frame, index) and the baseline it sits on, once that line is set.
let mut drop_line: Option<(usize, usize)> = None;
let mut drop_baseline: Option<f64> = None;
loop {
if cur.fi >= frames.len() {
out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start));
@@ -735,11 +783,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
_ => col,
};
// Estimate slots for the breaker with the paragraph's base leading.
let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp);
let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
let est_asc = base_chars.size * 0.75;
let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5 };
let est_first = cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp);
let est_jump = cur.jump(f, base_leading, est_asc, est_tops);
let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
let width = |j: usize| -> f64 {
let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1));
let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 };
// Beside the drop cap (in the column the paragraph starts in), its width; else the
// first-line indent.
let ind = pp.left_indent
+ pp.right_indent
+ match &drop_cap {
Some(dc) if line_no + j < dc.lines && col_first_line == 0 => dc.indent(),
_ if line_no + j == 0 => pp.first_line_indent,
_ => 0.0,
};
(x1 - x0 - ind).max(1.0)
};
let rest = &glyphs[g0..];
@@ -755,39 +814,36 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
for (k, b) in breaks.iter().enumerate() {
let (s, e) = (g0 + b.start, g0 + b.end);
let line_glyphs = &glyphs[s..e.max(s)];
let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
let (asc, tops, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
let reference = cjk_line_reference(line_glyphs);
let mut baseline = cur.next_baseline(f, col, lead, asc, &pp);
// Baseline grid: the next grid line at or below.
let grid = f.grid.filter(|&(_, inc)| {
inc > 0.0 && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
});
let snap = |y: f64| grid.map_or(y, |(g_start, inc)| g_start + ((y - g_start) / inc - 1e-6).ceil() * inc);
let mut baseline = cur.next_baseline(f, col, lead, asc, tops, &pp);
if cur.last_baseline.is_some() {
baseline += cur.last_reference - reference;
}
// Baseline grid.
if let Some((g_start, inc)) = f.grid
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
&& inc > 0.0
{
let n = ((baseline - g_start) / inc - 1e-6).ceil();
baseline = g_start + n * inc;
}
// Wrap: push the line down until a slot exists.
baseline = snap(baseline);
// Wrap: push the line down to the first position with a slot (back on the grid).
let (mut x0, mut x1) = (col.x0, col.x1);
if !f.exclusions.is_empty() {
let jump = cur.jump(f, lead, asc, tops);
let mut tries = 0;
loop {
match free_slot(f, col, baseline - asc, baseline + desc, base_size) {
Some((a, b)) => {
x0 = a;
x1 = b;
break;
}
None => {
baseline += 1.0;
tries += 1;
if baseline > col.y1 || tries > 4000 {
break;
}
}
let Some((nb, (a, z))) = wrap_slot(f, col, baseline, asc, desc, jump, base_size) else {
// No room in this column.
baseline = baseline.max(col.y1) + 1.0;
break;
};
let pushed = nb > baseline;
(baseline, x0, x1) = (nb, a, z);
tries += 1;
if !pushed || grid.is_none() || tries > 4000 {
break;
}
baseline = snap(baseline);
}
}
let capped = line_cap[pi] == Some(line_no) && line_no > col_first_line;
@@ -853,12 +909,39 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
moved = true;
break;
}
let ind_l = pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 };
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent);
let (ind_l, ind_r) = match beside {
Some(w) if rtl => (pp.left_indent, pp.right_indent + w),
Some(w) => (pp.left_indent + w, pp.right_indent),
None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent),
};
let lx0 = x0 + ind_l;
let lx1 = x1 - pp.right_indent;
let lx1 = x1 - ind_r;
let last = k + 1 == breaks.len();
let (mut placed, end_x, ratio) =
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page, &bidi_info);
// The spaces the break trimmed off the end follow the line undrawn, so the caret
// and the selection reach them; alignment and justification ignore them.
if pp.direction != designcraft_doc::TextDirection::RightToLeft && !placed.iter().any(|g| g.rtl) {
let mut x = end_x;
for g in glyphs.get(e..(g0 + b.next).min(glyphs.len())).unwrap_or(&[]).iter().filter(|g| g.is_space()) {
placed.push(PlacedGlyph { visible: false, ..place(g, x) });
x += g.adv;
}
}
let mut drop_box = None;
if line_no == 0
&& let Some(dc) = &drop_cap
{
let at = if rtl { x1 - pp.right_indent - dc.width } else { x0 + pp.left_indent - dc.lsb };
placed.splice(0..0, place_drop_cap(dc, at, rtl));
drop_box = Some(DropCapBox {
end: dc.end,
baseline: baseline + dc.drop,
rect: Rect::new(at, baseline - asc, at + dc.width, baseline + dc.drop + desc),
});
}
ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback);
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
@@ -885,7 +968,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
spacing: ratio,
hj: hj_severity(ratio, pp.word_space_min, pp.word_space_max),
keep_violation: false,
drop_cap: drop_box,
});
if drop_box.is_some() {
drop_line = Some((cur.fi, ft.lines.len() - 1));
}
if let Some(dc) = &drop_cap
&& line_no + 1 == dc.lines
&& col_first_line == 0
{
drop_baseline = Some(baseline);
}
for k in line_notes {
notes.place(doc, k, cur.fi, cur.col, col_w, f, opts);
}
@@ -934,6 +1027,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
break;
}
}
// The drop cap sits on its last line's baseline as set (it was placed at the leading's).
if let (Some((fi, li)), Some(b), Some(n)) = (drop_line, drop_baseline, drop_cap.as_ref().map(|dc| dc.glyphs.len()))
&& let Some(l) = out.frames.get_mut(fi).and_then(|f| f.lines.get_mut(li))
&& let Some(dc) = l.drop_cap.as_mut()
{
let d = b - dc.baseline;
dc.baseline = b;
dc.rect.y1 += d;
// Its glyphs start the line.
for g in l.glyphs.iter_mut().take(n) {
g.y += d;
}
}
info.push(ParaInfo {
start: start_at,
at_top,
@@ -953,11 +1059,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let r = &pp.rule_above;
let y = bl - asc - r.offset;
let col = ft.lines.iter().rev().find(|l| l.para == pi).map(|l| (l.x0, l.x1)).unwrap_or((0.0, 0.0));
ft.decos.push(Deco {
rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y),
color: r.color.clone(),
tint: r.tint,
});
let (color, tint) = rule_color(r, &above);
ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint });
}
}
para_box_decos(&mut out, pi, &pp);
@@ -968,7 +1071,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let r = &pp.rule_below;
let y = l.baseline + r.offset;
let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) };
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint });
let (color, tint) = rule_color(r, &below);
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint });
}
cur.pending += pp.space_after;
pi += 1;
@@ -1389,22 +1493,48 @@ impl Cursor {
self.last_baseline = None;
self.pending = 0.0;
}
/// Baseline for the next line with leading `lead` and ascent `asc`.
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 {
/// Baseline for the next line with leading `lead`, ascent `asc` and first baseline heights
/// `tops`.
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, tops: Tops, _pp: &ParaProps) -> f64 {
match self.last_baseline {
Some(b) => b + lead + self.pending,
None => {
let off = match f.opts.first_baseline {
FirstBaseline::Ascent => asc,
FirstBaseline::CapHeight => asc * 0.72,
FirstBaseline::XHeight => asc * 0.5,
FirstBaseline::Leading => lead,
FirstBaseline::Fixed => 0.0,
};
col.y0 + off.max(f.opts.first_baseline_min)
}
None => col.y0 + first_baseline_offset(f, lead, asc, tops),
}
}
/// How the next line clears a Jump Object wrap: by whole leadings below the line above, or, first
/// in its column, as at the top of a frame whose top is the wrap bottom.
fn jump(&self, f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> Jump {
match self.last_baseline {
Some(_) => Jump { top: lead, step: lead },
None => Jump { top: first_baseline_offset(f, lead, asc, tops), step: 0.0 },
}
}
}
/// The frame's first baseline offset below the top of a column, for a line with leading `lead`,
/// ascent `asc` and first baseline heights `tops`.
fn first_baseline_offset(f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> f64 {
let off = match f.opts.first_baseline {
// Vertical frames measure from the ascent.
FirstBaseline::Ascent if f.vertical => asc,
FirstBaseline::CapHeight if f.vertical => asc * 0.72,
FirstBaseline::XHeight if f.vertical => asc * 0.5,
FirstBaseline::Ascent => tops.typo_ascent,
FirstBaseline::CapHeight => tops.cap,
FirstBaseline::XHeight => tops.xh,
FirstBaseline::Leading => lead,
FirstBaseline::Fixed => 0.0,
};
off.max(f.opts.first_baseline_min)
}
/// A line's tallest typographic ascender, cap height and x height above its baseline: the
/// first baseline offsets.
#[derive(Clone, Copy, Default)]
struct Tops {
typo_ascent: f64,
cap: f64,
xh: f64,
}
/// Em box relative to the baseline. Font ascender/descender proportions locate it;
@@ -1447,24 +1577,88 @@ fn line_metrics(
base_size: f64,
db: &ScopedFonts<'_>,
base: &designcraft_doc::CharProps,
) -> (f64, f64, f64) {
) -> (f64, Tops, f64, f64) {
let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line };
if src.is_empty() {
let face = db.face(&base.font_family, &base.font_style);
let k = base_size / face.upem;
return (face.ascent * k, face.descent * k, base_leading);
let tops = Tops { typo_ascent: face.typo_ascent * k, cap: face.cap_height * k, xh: face.x_height * k };
return (face.ascent * k, tops, face.descent * k, base_leading);
}
let mut asc: f64 = 0.0;
let mut tops = Tops::default();
let mut desc: f64 = 0.0;
let mut lead: f64 = 0.0;
let reference = src.iter().max_by(|a, b| a.size.total_cmp(&b.size));
for g in src {
let shift = g.shift + reference.map_or(0.0, |r| cjk_alignment_shift(g, r));
asc = asc.max(g.ascent + shift.max(0.0));
let up = shift.max(0.0);
asc = asc.max(g.ascent + up);
tops.typo_ascent = tops.typo_ascent.max(g.typo_ascent + up);
tops.cap = tops.cap.max(g.cap + up);
tops.xh = tops.xh.max(g.xh + up);
desc = desc.max(g.descent - shift.min(0.0));
lead = lead.max(g.leading);
}
(asc, desc, lead)
(asc, tops, desc, lead)
}
/// How a line clears Jump Object wraps. The line occupies `[baseline − top, baseline]` (its leading,
/// or its first baseline offset at the top of a column) and must not overlap the wrap; it may hang its
/// descenders into the wrap. Below a wrap it moves down from its place in whole `step`s until it clears
/// the wrap bottom (`step` 0: its top lands on the wrap bottom).
#[derive(Clone, Copy, Debug)]
struct Jump {
top: f64,
step: f64,
}
/// Slack for float error when a line is tested against a Jump Object wrap's edges.
const JUMP_EPS: f64 = 1e-6;
/// The first baseline at or below `baseline` where a line with ascent `asc` and descent `desc` has a
/// free slot in `col`, and that slot; None if there is none above the column bottom.
///
/// A line under a Jump Object wrap moves below it as `jump` says. Other wraps are rectangles, so the
/// free space only grows where one in the line's band ends: the search jumps from one such bottom
/// edge to the next, and a line that clears a wrap has its top exactly on the wrap's bottom.
fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: Jump, size: f64) -> Option<(f64, (f64, f64))> {
let top = jump.top.max(0.0);
let mut b = baseline;
// Each step passes at least one wrap; the cap bounds hostile geometry.
for _ in 0..4000 {
let jumped = f
.exclusions
.iter()
.filter(|e| e.mode == WrapMode::JumpObject)
.map(|e| e.rect)
.filter(|r| r.x0 < col.x1 && r.x1 > col.x0 && b > r.y0 + JUMP_EPS && b - top < r.y1 - JUMP_EPS)
.fold(f64::NEG_INFINITY, |m, r| m.max(r.y1));
b = if jumped > f64::NEG_INFINITY {
if jump.step > 0.0 {
// Whole steps from the line's own place (at least one step past the line above).
baseline + ((jumped + top - baseline - JUMP_EPS / 2.0) / jump.step).ceil().max(0.0) * jump.step
} else {
jumped + top
}
} else if let Some(slot) = free_slot(f, col, b - asc, b + desc, size) {
return Some((b, slot));
} else {
let (y0, y1) = (b - asc, b + desc);
let edge = f
.exclusions
.iter()
.filter(|e| e.mode != WrapMode::JumpObject)
.map(|e| e.rect)
.filter(|r| r.y0 < y1 && r.y1 > y0)
.fold(f64::INFINITY, |m, r| m.min(r.y1));
edge + asc
};
if b.is_nan() || b > col.y1 {
return None;
}
}
None
}
/// Widest free horizontal interval of `col` in the band, or None if blocked.
@@ -1475,7 +1669,6 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f
let overlaps_band = r.y0 < y1 && r.y1 > y0;
match ex.mode {
WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None,
WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None,
WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => {
let mut next = Vec::new();
for (a, b) in free {
@@ -1499,7 +1692,17 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f
}
#[allow(clippy::too_many_arguments)]
fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], pp: &ParaProps, line_no: usize) -> Vec<(f64, f64)> {
fn estimate_slots(
f: &FrameSpec,
col: Rect,
first: f64,
jump: Jump,
lead: f64,
size: f64,
glyphs: &[Glyph],
pp: &ParaProps,
line_no: usize,
) -> Vec<(f64, f64)> {
let _ = (pp, line_no);
if f.exclusions.is_empty() {
return vec![];
@@ -1509,20 +1712,21 @@ fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, gl
let n = ((total / (col.width().max(10.0) * 0.5)).ceil() as usize + 2).min(2000);
let mut v = Vec::with_capacity(n);
let mut b = first;
let mut jump = jump;
for _ in 0..n {
let mut tries = 0;
let slot = loop {
if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) {
break s;
let slot = match wrap_slot(f, col, b, size * 0.8, size * 0.25, jump, size) {
Some((nb, s)) => {
b = nb;
s
}
b += 1.0;
tries += 1;
if b > col.y1 || tries > 4000 {
break (col.x0, col.x1);
None => {
b = b.max(col.y1);
(col.x0, col.x1)
}
};
v.push(slot);
b += lead;
jump = Jump { top: lead, step: lead };
}
v
}
@@ -2002,6 +2206,122 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph {
}
}
/// Drop caps are at most this many lines and characters (larger values are capped).
pub const MAX_DROP_CAP_LINES: u32 = 25;
pub const MAX_DROP_CAP_CHARS: u32 = 150;
/// Byte where paragraph `prange`'s drop cap ends: after its first `drop_cap_chars` characters
/// (code points, as InDesign counts them, spaces included), extended to the end of the grapheme
/// cluster they end in so a letter keeps its combining marks; all of them in a shorter paragraph.
/// `prange.start` when it has none: no lines or characters, or vertical text (which sets no drop
/// caps).
fn drop_cap_end(text: &str, prange: Range<usize>, pp: &ParaProps, vertical: bool) -> usize {
use unicode_segmentation::UnicodeSegmentation as _;
if vertical || pp.drop_cap_lines == 0 || pp.drop_cap_chars == 0 {
return prange.start;
}
let Some(para) = text.get(prange.clone()) else { return prange.start };
let para = para.trim_end_matches('\n');
let n = pp.drop_cap_chars.min(MAX_DROP_CAP_CHARS) as usize;
let end = para.char_indices().nth(n).map_or(para.len(), |(i, _)| i);
prange.start + para.grapheme_indices(true).map(|(i, _)| i).find(|i| *i >= end).unwrap_or(para.len())
}
/// A paragraph's drop cap: its first glyphs, enlarged to reach down `lines` lines.
struct DropCap {
glyphs: Vec<Glyph>,
/// Story byte it ends at.
end: usize,
lines: usize,
/// Its advance.
width: f64,
/// How far its origin sits left of the indent: its first letter's left side bearing with
/// Align Left Edge, else 0.
lsb: f64,
/// Its baseline below the first line's, at the paragraph's leading.
drop: f64,
}
/// Take the drop cap (the glyphs before byte `end`) off the front of a paragraph's glyphs and scale
/// it so its cap height reaches from the first line's cap height down to line `lines`' baseline,
/// measured in the leading and cap height of the text after it.
/// On a baseline grid of increment `grid`, lines are that many grid steps apart. With
/// `align_left` its ink starts at the indent (see [`DropCap::lsb`]).
#[allow(clippy::too_many_arguments)]
fn split_drop_cap(
db: &ScopedFonts<'_>,
glyphs: &mut Vec<Glyph>,
end: usize,
lines: u32,
base_leading: f64,
base_cap: f64,
grid: Option<f64>,
align_left: bool,
) -> Option<DropCap> {
let n = glyphs.iter().take_while(|g| g.byte < end).count();
if n == 0 {
return None;
}
let lines = lines.clamp(1, MAX_DROP_CAP_LINES) as usize;
let mut dc: Vec<Glyph> = glyphs.drain(..n).collect();
let body = glyphs.iter().find(|g| g.len > 0 && g.cap > 0.0);
let (lead, cap) = body.map_or((base_leading, base_cap), |g| (g.leading, g.cap));
let lead = match grid.map(|inc| (lead / inc - 1e-6).ceil().max(1.0) * inc) {
Some(pitch) if pitch.is_finite() && pitch > 0.0 => pitch,
_ => lead,
};
let drop = (lines - 1) as f64 * lead;
let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max);
let k = if own > 0.0 { (drop + cap) / own } else { 1.0 };
let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 };
for g in &mut dc {
g.adv *= k;
g.dx *= k;
g.dy *= k;
g.sx *= k;
g.sy *= k;
g.ascent *= k;
g.typo_ascent *= k;
g.descent *= k;
g.cap *= k;
g.xh *= k;
g.size *= k;
g.space *= k;
}
let width = dc.iter().map(|g| g.adv).sum();
let lsb = match dc.first() {
Some(g) if align_left => {
let ink = designcraft_geom::Shape::bounding_box(&*db.outline(g.face.get(), g.gid));
let lsb = g.dx + ink.x0 * g.sx;
if ink.width() > 0.0 && lsb.is_finite() { lsb } else { 0.0 }
}
_ => 0.0,
};
Some(DropCap { glyphs: dc, end, lines, width, drop, lsb })
}
impl DropCap {
/// How far the lines beside it are indented: to the end of its advance.
fn indent(&self) -> f64 {
self.width - self.lsb
}
}
/// The drop cap's glyphs set from `at` (right to left: mirrored, its first character on the right).
fn place_drop_cap(dc: &DropCap, at: f64, rtl: bool) -> Vec<PlacedGlyph> {
let mut x = at;
let mut out = Vec::with_capacity(dc.glyphs.len());
for g in &dc.glyphs {
let pen = if rtl { at + at + dc.width - x - g.adv } else { x };
let mut p = place(g, pen);
p.y += dc.drop;
p.rtl = rtl;
out.push(p);
x += g.adv;
}
out
}
fn prepend_label(
db: &ScopedFonts<'_>,
glyphs: &mut Vec<Glyph>,
@@ -2015,7 +2335,7 @@ fn prepend_label(
let mut tmp = Story::new(StoryId(0));
tmp.insert(0, label);
let styles = designcraft_doc::Styles::default();
let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[]);
let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[], (0, ""));
let mut pre: Vec<Glyph> = shaped
.glyphs
.into_iter()
@@ -2158,6 +2478,12 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec) {
/// Caret geometry for story byte `pos`: (frame index, x, baseline, ascent, descent).
pub fn caret(cs: &ComposedStory, pos: usize) -> Option<(usize, f64, f64, f64, f64)> {
let (fi, l) = caret_line(cs, pos)?;
// In the drop cap the caret is as tall as it.
if let Some(dc) = l.drop_cap
&& pos < dc.end
{
return Some((fi, caret_x(l, pos), dc.baseline, dc.baseline - dc.rect.y0, l.descent));
}
Some((fi, caret_x(l, pos), l.baseline, l.ascent, l.descent))
}
@@ -2198,6 +2524,8 @@ pub fn caret_x(l: &Line, pos: usize) -> f64 {
}
match l.glyphs.iter().rev().find(|g| g.len > 0) {
Some(g) if g.rtl => g.x,
// After the spaces that end the line.
Some(g) if !g.visible && g.x >= l.end_x - 1e-9 => g.x + g.adv,
_ => l.end_x,
}
}
@@ -2231,19 +2559,57 @@ pub fn line_rtl(cs: &ComposedStory, pos: usize) -> bool {
caret_line(cs, pos).and_then(|(_, l)| l.glyphs.iter().find(|g| g.len > 0)).is_some_and(|g| g.rtl)
}
/// The line of frame `ft` closest to point `p` vertically (within its column).
fn hit_line(ft: &FrameText, p: Point) -> Option<&Line> {
ft.lines
.iter()
.filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1)
.min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y)))
.or_else(|| ft.lines.first())
}
/// Story byte of the character drawn under point `p` (frame inner space) in frame `fi`: the
/// glyph whose advance spans `p.x` on the closest line, else the one nearest it. For selecting
/// the word (or the spaces) under a double click; [`hit`] finds a caret position instead.
pub fn hit_char(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> {
let ft = cs.frames.get(fi)?;
let Some(l) = hit_line(ft, p) else { return Some(ft.range.start) };
let dist = |g: &&PlacedGlyph| {
let (a, b) = (g.x.min(g.x + g.adv), g.x.max(g.x + g.adv));
if p.x < a {
a - p.x
} else if p.x >= b {
p.x - b
} else {
0.0
}
};
// On a drop cap (it reaches down beside the lines after its own): its character under `p`.
for dl in &ft.lines {
let Some(dc) = dl.drop_cap.filter(|dc| dc.rect.contains(p)) else { continue };
return dl.glyphs.iter().filter(|g| g.len > 0 && g.byte < dc.end).min_by(|a, b| dist(a).total_cmp(&dist(b))).map(|g| g.byte);
}
let under = l.glyphs.iter().filter(|g| g.len > 0).min_by(|a, b| dist(a).total_cmp(&dist(b)));
Some(under.map_or(l.range.start, |g| g.byte))
}
/// Story byte nearest to point `p` (frame inner space) in frame `fi`.
pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> {
let ft = cs.frames.get(fi)?;
if ft.lines.is_empty() {
return Some(ft.range.start);
}
// Closest line vertically (within its column).
let l = ft
.lines
.iter()
.filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1)
.min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y)))
.or_else(|| ft.lines.first())?;
// On a drop cap (it reaches down beside the lines after its own): the nearest caret position in it.
for l in &ft.lines {
let Some(dc) = l.drop_cap.filter(|dc| dc.rect.contains(p)) else { continue };
return l
.glyphs
.iter()
.filter(|g| g.len > 0 && g.byte < dc.end)
.flat_map(|g| [g.byte, g.byte + g.len])
.min_by(|a, b| (caret_x(l, *a) - p.x).abs().total_cmp(&(caret_x(l, *b) - p.x).abs()));
}
let l = hit_line(ft, p)?;
if l.glyphs.iter().any(|g| g.rtl) {
// Bidi: the caret position drawn nearest the point.
return caret_stops(l).into_iter().min_by(|a, b| (a.1 - p.x).abs().total_cmp(&(b.1 - p.x).abs())).map(|s| s.0);
+6 -1
View File
@@ -197,10 +197,15 @@ pub fn find_note(cs: &ComposedStory, id: u64) -> Option<(usize, &PlacedNote)> {
/// Footnote text hit at frame-inner point `p`: (footnote id, byte in the footnote's story).
pub fn hit_note(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize)> {
hit_note_with(cs, fi, p, crate::hit)
}
/// [`hit_note`] with the hit test for the note's text (see [`crate::hit_cell_with`]).
pub fn hit_note_with(cs: &ComposedStory, fi: usize, p: Point, hit: fn(&ComposedStory, usize, Point) -> Option<usize>) -> Option<(u64, usize)> {
let ft = cs.frames.get(fi)?;
let n = ft.notes.iter().find(|n| n.rect.contains(p))?;
let local = Point::new(p.x - n.origin.x, p.y - n.origin.y);
Some((n.id, crate::hit(&n.text, 0, local).unwrap_or(0)))
Some((n.id, hit(&n.text, 0, local).unwrap_or(0)))
}
/// Caret geometry for byte `pos` of a footnote's story, in frame inner space:
+29 -14
View File
@@ -22,12 +22,15 @@ fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) ->
NestedUntil::ForcedLineBreak => c == designcraft_doc::FORCED_LINE_BREAK,
NestedUntil::EmSpace => c == '\u{2003}',
NestedUntil::EnSpace => c == '\u{2002}',
// Counted by position, not by character (see `overlays`).
NestedUntil::Dropcap => false,
NestedUntil::Chars(s) => s.contains(c),
}
}
/// Character-style ranges for the paragraph `text[range]`, in application order (later wins).
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle]) -> Vec<(Range<usize>, String)> {
/// The paragraph's drop cap ends at `drop_end` (`range.start` without one).
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle], drop_end: usize) -> Vec<(Range<usize>, String)> {
let mut out = Vec::new();
let para = &text[range.clone()];
// The paragraph's own end mark isn't styled.
@@ -37,17 +40,22 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
if pos >= end {
break;
}
let mut count = 0;
let mut stop = end;
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
for (k, &(i, c)) in chars.iter().enumerate() {
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
let next = chars.get(k + 1).map(|x| x.1);
if is_event(&ns.until, c, prev, next) {
count += 1;
if count >= ns.count.max(1) {
stop = if ns.through { i + c.len_utf8() } else { i };
break;
if ns.until == NestedUntil::Dropcap {
// Through the drop cap runs to its end; up to it (it starts the paragraph) is nothing.
stop = if ns.through { drop_end.max(pos).min(end) } else { pos };
} else {
let mut count = 0;
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
for (k, &(i, c)) in chars.iter().enumerate() {
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
let next = chars.get(k + 1).map(|x| x.1);
if is_event(&ns.until, c, prev, next) {
count += 1;
if count >= ns.count.max(1) {
stop = if ns.through { i + c.len_utf8() } else { i };
break;
}
}
}
}
@@ -84,14 +92,21 @@ mod tests {
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
let n = |style: &str, through: bool, count: u32, until: NestedUntil| NestedStyle { style: style.into(), through, count, until };
// Through the first ':' then up to the second word end.
let o = overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[]);
let o =
overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[], 0);
assert_eq!(&text[o[0].0.clone()], "Q:");
assert_eq!(&text[o[1].0.clone()], " what is", "up to the end of the second word");
// Digits by GREP.
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }]);
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }], 0);
let got: Vec<&str> = o.iter().map(|(r, _)| &text[r.clone()]).collect();
assert_eq!(got, ["42", "7"]);
// Through the drop cap (its first 2 bytes here); up to it is nothing; without one, nothing.
let d = |through: bool| n("Cap", through, 1, NestedUntil::Dropcap);
let o = overlays(&text, 0..text.len(), &[d(true), n("Next", true, 1, NestedUntil::Words)], &[], 2);
assert_eq!((&text[o[0].0.clone()], &text[o[1].0.clone()]), ("Q:", " what "));
assert_eq!(&text[overlays(&text, 0..text.len(), &[d(false), n("Next", true, 1, NestedUntil::Words)], &[], 2)[0].0.clone()], "Q: ");
assert!(overlays(&text, 0..text.len(), &[d(true)], &[], 0).is_empty());
// A bad pattern is ignored.
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }]).is_empty());
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }], 0).is_empty());
}
}
+147 -36
View File
@@ -28,9 +28,13 @@ pub struct Glyph {
/// Baseline shift (positive = up), including super/subscript.
pub shift: f64,
pub ascent: f64,
/// The font's typographic ascender, scaled like `ascent`: the Ascent first baseline offset.
pub typo_ascent: f64,
pub descent: f64,
/// The leading this character asks for (absolute, or auto = size × auto %).
pub leading: f64,
/// Cap height and x height, scaled like `ascent`: the Cap Height and x Height first baseline
/// offsets.
pub cap: f64,
pub xh: f64,
pub size: f64,
@@ -84,6 +88,58 @@ pub struct ShapedPara {
pub range: std::ops::Range<usize>,
}
/// A resolved piece of a paragraph: one character format (and overlay) over `range`.
struct Piece {
range: std::ops::Range<usize>,
props: CharProps,
style: u32,
/// Shaping settings for the style (a missing font turns glyph fallback on).
env: TypeEnv,
/// Hidden conditional text or tracked deletions: laid out as zero-width place-holders.
hidden: bool,
}
/// The style of each byte of a shaped run: `(first byte, style)` in story order.
struct StyleRuns<'a>(&'a [(usize, u32)]);
impl StyleRuns<'_> {
fn at(&self, byte: usize) -> u32 {
let k = self.0.partition_point(|&(start, _)| start <= byte);
self.0.get(k.saturating_sub(1)).map_or(0, |&(_, s)| s)
}
}
/// The attributes that decide how text is shaped: `p` with everything that only paints the
/// glyphs (colour, stroke, underline and strikethrough, skew, markers) reset, so two pieces with
/// equal keys can be shaped as one. Attributes not listed here keep pieces apart.
fn shaping_key(p: &CharProps) -> CharProps {
let d = CharProps::default();
CharProps {
skew: d.skew,
fill: d.fill,
fill_tint: d.fill_tint,
stroke: d.stroke,
stroke_tint: d.stroke_tint,
stroke_weight: d.stroke_weight,
underline: d.underline,
strikethrough: d.strikethrough,
underline_weight: d.underline_weight,
underline_offset: d.underline_offset,
underline_color: d.underline_color,
underline_tint: d.underline_tint,
strikethrough_weight: d.strikethrough_weight,
strikethrough_offset: d.strikethrough_offset,
strikethrough_color: d.strikethrough_color,
strikethrough_tint: d.strikethrough_tint,
ruby: d.ruby,
kenten: d.kenten,
conditions: d.conditions,
change: d.change,
xml_tag: d.xml_tag,
..p.clone()
}
}
/// Context for substitutions (page number markers).
#[derive(Clone, Debug, Default)]
pub struct SubstCtx {
@@ -178,7 +234,8 @@ impl StyleTable<'_> {
}
}
/// Resolve and shape one paragraph.
/// Resolve and shape one paragraph. Its drop cap ends at byte `drop.0` (`range.start` without
/// one) and takes the character style `drop.1` ("" = none).
pub(crate) fn shape_para(
db: &ScopedFonts<'_>,
styles: &Styles,
@@ -192,13 +249,18 @@ pub(crate) fn shape_para(
nested: &[designcraft_doc::NestedStyle],
grep: &[designcraft_doc::GrepStyle],
lines: &[(std::ops::Range<usize>, String)],
drop: (usize, &str),
) -> ShapedPara {
let _ = pi;
let mut glyphs = Vec::with_capacity(range.len());
// Nested line styles lie under nested and GREP styles (later overlays win).
// Nested line styles lie under nested and GREP styles, and the drop cap's style over them all
// (later overlays win).
let mut overlays: Vec<(std::ops::Range<usize>, String)> = lines.to_vec();
if !nested.is_empty() || !grep.is_empty() {
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep));
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0));
}
if drop.0 > range.start && !drop.1.is_empty() && drop.1 != designcraft_doc::NO_CHAR_STYLE {
overlays.push((range.start..drop.0, drop.1.to_string()));
}
// Each run, cut where nested / GREP styles start and end.
let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new();
@@ -222,6 +284,8 @@ pub(crate) fn shape_para(
segments.push((w[0], w[1], top, fmt));
}
}
// Resolve every segment, then shape runs of segments that differ only in how they are
// painted as one piece, so kerning, ligatures and joining work across a colour change.
let mut resolved = Vec::new();
for (a, b, overlay, fmt) in segments {
// A nested / GREP character style sits under the run's own style and overrides.
@@ -251,29 +315,19 @@ pub(crate) fn shape_para(
resolved.push((a, b, props, fmt, para_chars.clone()));
}
}
let mut pieces: Vec<Piece> = Vec::with_capacity(resolved.len());
for (a, b, props, fmt, resolved_base) in resolved {
let style = table.intern(db, &props);
// A missing font's substitute stands in for the whole font: fallback fonts help it.
let auto_leading = table.env_for(auto_leading, style);
let deleted = props.change == designcraft_doc::ChangeMark::Deleted;
if deleted || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))) {
// Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every
// byte addressable (caret, selection) without taking space.
let face = db.face(&props.font_family, &props.font_style);
for (i, c) in story.text[a..b].char_indices() {
if c == '\n' {
continue;
}
let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c);
g.ch = HIDDEN;
(g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0);
glyphs.push(g);
}
pieces.push(Piece { range: a..b, props, style, env: auto_leading, hidden: true });
continue;
}
let is_ref = |c: char| c == designcraft_doc::FOOTNOTE_REF || c == designcraft_doc::ENDNOTE_REF;
if !story.text[a..b].contains(is_ref) {
shape_run(db, &story.text, a..b, &props, auto_leading, style, sub, &mut glyphs);
pieces.push(Piece { range: a..b, props, style, env: auto_leading, hidden: false });
continue;
}
// Footnote references take the reference position / character style; endnote references
@@ -295,20 +349,67 @@ pub(crate) fn shape_para(
for (i, m) in story.text[a..b].match_indices(is_ref) {
let i = a + i;
if k < i {
shape_run(db, &story.text, k..i, &props, auto_leading, style, sub, &mut glyphs);
pieces.push(Piece { range: k..i, props: props.clone(), style, env: auto_leading, hidden: false });
}
let e = i + m.len();
if m.starts_with(designcraft_doc::ENDNOTE_REF) {
shape_run(db, &story.text, i..e, &eprops, eenv, estyle, sub, &mut glyphs);
pieces.push(Piece { range: i..e, props: eprops.clone(), style: estyle, env: eenv, hidden: false });
} else {
shape_run(db, &story.text, i..e, &rprops, renv, rstyle, sub, &mut glyphs);
pieces.push(Piece { range: i..e, props: rprops.clone(), style: rstyle, env: renv, hidden: false });
}
k = e;
}
if k < b {
shape_run(db, &story.text, k..b, &props, auto_leading, style, sub, &mut glyphs);
pieces.push(Piece { range: k..b, props, style, env: auto_leading, hidden: false });
}
}
let mut i = 0;
while i < pieces.len() {
let first = &pieces[i];
if first.hidden {
// Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every
// byte addressable (caret, selection) without taking space.
let face = db.face(&first.props.font_family, &first.props.font_style);
for (k, c) in story.text[first.range.clone()].char_indices() {
if c == '\n' {
continue;
}
let mut g = control_glyph(&face, &first.props, first.env, first.style, first.range.start + k, c);
g.ch = HIDDEN;
(g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
glyphs.push(g);
}
i += 1;
continue;
}
let key = shaping_key(&first.props);
let mut j = i + 1;
// Jidori fits its own run into whole ems: such pieces stay apart.
while j < pieces.len()
&& first.props.jidori == 0
&& !pieces[j].hidden
&& pieces[j].range.start == pieces[j - 1].range.end
&& pieces[j].env.glyph_fallback == first.env.glyph_fallback
&& shaping_key(&pieces[j].props) == key
{
j += 1;
}
let group = &pieces[i..j];
let range = group[0].range.start..group[j - i - 1].range.end;
let runs: Vec<(usize, u32)> = group.iter().map(|p| (p.range.start, p.style)).collect();
let start = glyphs.len();
shape_run(db, &story.text, range, &first.props, first.env, &StyleRuns(&runs), sub, &mut glyphs);
// A cluster (a ligature, a letter with its marks) that spans two styles would paint one
// of them in the other's colour: shape those pieces apart instead.
let spans_cut = |g: &Glyph| runs[1..].iter().any(|&(cut, _)| g.byte < cut && cut < g.byte + g.len);
if group.len() > 1 && glyphs[start..].iter().any(spans_cut) {
glyphs.truncate(start);
for p in group {
shape_run(db, &story.text, p.range.clone(), &p.props, p.env, &StyleRuns(&[(p.range.start, p.style)]), sub, &mut glyphs);
}
}
i = j;
}
collapse_tcy(&mut glyphs, sub.vertical);
ShapedPara { glyphs, range }
}
@@ -429,12 +530,12 @@ fn shape_run(
range: std::ops::Range<usize>,
p: &CharProps,
env: TypeEnv,
style: u32,
styles: &StyleRuns<'_>,
sub: &SubstCtx,
out: &mut Vec<Glyph>,
) {
let start = out.len();
shape_run_raw(db, text, range, p, env, style, sub, out);
shape_run_raw(db, text, range, p, env, styles, sub, out);
let run = &mut out[start..];
let em = p.size * p.h_scale;
for g in run.iter_mut().filter(|g| g.adv > 0.0 && !g.ch.is_control()) {
@@ -484,7 +585,7 @@ fn shape_run_raw(
range: std::ops::Range<usize>,
p: &CharProps,
auto_leading: TypeEnv,
style: u32,
styles: &StyleRuns<'_>,
sub: &SubstCtx,
out: &mut Vec<Glyph>,
) {
@@ -506,7 +607,7 @@ fn shape_run_raw(
let mut seg_face = primary.clone();
let flush = |a: usize, b: usize, face: &Arc<FontFace>, out: &mut Vec<Glyph>| {
if a < b {
shape_segment(db, text, a..b, None, p, face, auto_leading, style, out, sub.vertical);
shape_segment(db, text, a..b, None, p, face, auto_leading, styles, out, sub.vertical);
}
};
for (i, c) in text[range.clone()].char_indices() {
@@ -531,7 +632,7 @@ fn shape_run_raw(
flush(seg_start, i, &seg_face, out);
seg_start = i + c.len_utf8();
let face = if primary.covers(d) { primary.clone() } else { fallback(d) };
shape_segment(db, text, i..i + c.len_utf8(), Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, style, out, sub.vertical);
shape_segment(db, text, i..i + c.len_utf8(), Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, styles, out, sub.vertical);
continue;
}
}
@@ -561,9 +662,9 @@ fn shape_run_raw(
seg_start = i + c.len_utf8();
if let Some(vi) = designcraft_doc::vars::var_index(c) {
match sub.vars.get(vi).filter(|v| !v.is_empty()) {
Some(v) => shape_segment(db, text, i..i + c.len_utf8(), Some(v), p, &primary, auto_leading, style, out, sub.vertical),
Some(v) => shape_segment(db, text, i..i + c.len_utf8(), Some(v), p, &primary, auto_leading, styles, out, sub.vertical),
None => {
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c);
g.adv = 0.0;
out.push(g);
}
@@ -577,10 +678,10 @@ fn shape_run_raw(
} else {
sub.page_name.clone().unwrap_or_else(|| "#".into())
};
shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out, sub.vertical);
shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, styles, out, sub.vertical);
}
designcraft_doc::OBJECT_MARK => {
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c);
if let Some(o) = sub.objects.get(&i).filter(|o| !o.custom) {
let auto = matches!(p.leading, Leading::Auto);
match o.y_offset {
@@ -588,10 +689,18 @@ fn shape_run_raw(
Some(y) => {
g.adv = o.w;
g.ascent = g.ascent.max(o.h + y);
g.typo_ascent = g.typo_ascent.max(o.h + y);
g.cap = g.cap.max(o.h + y);
g.xh = g.xh.max(o.h + y);
g.descent = g.descent.max(-y);
}
// Pushes its line down by its height and spacing.
None => g.ascent += o.h + o.space,
None => {
g.ascent += o.h + o.space;
g.typo_ascent += o.h + o.space;
g.cap += o.h + o.space;
g.xh += o.h + o.space;
}
}
if auto || o.y_offset.is_none() {
g.leading = g.leading.max(g.ascent + g.descent);
@@ -600,20 +709,20 @@ fn shape_run_raw(
out.push(g);
}
designcraft_doc::XREF_MARK => match sub.xrefs.get(&i).filter(|t| !t.is_empty()) {
Some(t) => shape_segment(db, text, i..i + c.len_utf8(), Some(t), p, &primary, auto_leading, style, out, sub.vertical),
Some(t) => shape_segment(db, text, i..i + c.len_utf8(), Some(t), p, &primary, auto_leading, styles, out, sub.vertical),
None => {
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c);
g.adv = 0.0;
out.push(g);
}
},
designcraft_doc::FOOTNOTE_REF | designcraft_doc::ENDNOTE_REF => {
let s = sub.notes.get(&i).map_or("#", String::as_str);
shape_segment(db, text, i..i + c.len_utf8(), Some(s), p, &primary, auto_leading, style, out, sub.vertical);
shape_segment(db, text, i..i + c.len_utf8(), Some(s), p, &primary, auto_leading, styles, out, sub.vertical);
}
_ => {
// Zero-width control glyph carrying metrics (tabs get their width at line layout).
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c);
if c == '\t'
|| c == story::RIGHT_INDENT_TAB
|| c == story::TABLE_ANCHOR
@@ -762,6 +871,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k * p.v_scale,
descent,
leading,
cap,
@@ -799,7 +909,7 @@ fn shape_segment(
p: &CharProps,
face: &Arc<FontFace>,
auto_leading: TypeEnv,
style: u32,
styles: &StyleRuns<'_>,
out: &mut Vec<Glyph>,
vertical: bool,
) {
@@ -880,12 +990,13 @@ fn shape_segment(
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k * p.v_scale,
descent,
leading,
cap,
xh,
size,
style,
style: styles.at(byte),
no_break: p.no_break,
locked_advance: p.jidori > 0,
tcy: p.tate_chu_yoko.then_some([0.0; 3]),
+13 -1
View File
@@ -417,6 +417,7 @@ fn emit(
spacing: 1.0,
hj: 0,
keep_violation: false,
drop_cap: None,
});
ft.tables.push(TableFrag {
table: table.id,
@@ -435,6 +436,17 @@ fn emit(
/// The cell under `p` (frame inner space) in frame `fi`: (table, row, col, story byte in the cell).
pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize, usize, usize)> {
hit_cell_with(cs, fi, p, crate::hit)
}
/// [`hit_cell`] with the hit test for the cell's text: [`crate::hit`] (caret position) or
/// [`crate::hit_char`] (character under the point).
pub fn hit_cell_with(
cs: &ComposedStory,
fi: usize,
p: Point,
hit: fn(&ComposedStory, usize, Point) -> Option<usize>,
) -> Option<(u64, usize, usize, usize)> {
let ft = cs.frames.get(fi)?;
for t in &ft.tables {
if !t.rect.contains(p) {
@@ -443,7 +455,7 @@ pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize,
for c in &t.cells {
if c.rect.contains(p) {
let local = Point::new(p.x - c.origin.x, p.y - c.origin.y);
let b = crate::hit(&c.text, 0, local).unwrap_or(0);
let b = hit(&c.text, 0, local).unwrap_or(0);
return Some((t.table, c.row, c.col, b));
}
}
+828 -1
View File
@@ -146,6 +146,241 @@ fn wrap_pushes_text_aside() {
assert!(below.x0 < 1.0);
}
/// Insert rectangles with wrap, given as (bounds, [top, left, bottom, right] offsets, mode).
fn add_wrap_objects(d: &mut Document, objects: &[(Rect, [f64; 4], WrapMode)]) {
let lid = d.default_layer();
for &(r, offsets, mode) in objects {
let id = ItemId(d.alloc());
let mut it = designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.wrap.mode = mode;
it.wrap.offsets = offsets;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
}
}
/// 10 pt on 12 pt text in a 288 × 528 frame at (36, 36), with wrap objects.
fn wrap_doc(para: ParaAttrs, objects: &[(Rect, [f64; 4], WrapMode)]) -> (Document, StoryId) {
let text = [LOREM; 6].join(" ");
let (mut d, sid, _) = doc_with(&text, Rect::new(36.0, 36.0, 324.0, 564.0), para);
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
f.over.size = Some(10.0);
f.over.leading = Some(designcraft_doc::Leading::Points(12.0));
});
add_wrap_objects(&mut d, objects);
(d, sid)
}
/// The first line whose baseline is below the top of `ex`.
fn first_line_below<'a>(cs: &'a ComposedStory, ex: &Exclusion) -> &'a Line {
cs.frames[0].lines.iter().find(|l| l.baseline > ex.rect.y0).unwrap()
}
/// 14 pt on 18 pt `text` in a frame from y 62.36 to 532.91 with first baseline offset Leading, so lines
/// sit at 80.36 + n × 18 (the setup measured in InDesign), with Jump Object wraps given as
/// (bounds, [top, left, bottom, right] offsets).
fn jump_doc(text: &str, objects: &[(Rect, [f64; 4])]) -> (Document, StoryId, ItemId) {
let (mut d, sid, fid) = doc_with(text, Rect::new(36.0, 62.36, 324.0, 532.91), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
f.over.size = Some(14.0);
f.over.leading = Some(designcraft_doc::Leading::Points(18.0));
});
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading;
let objects: Vec<_> = objects.iter().map(|&(r, o)| (r, o, WrapMode::JumpObject)).collect();
add_wrap_objects(&mut d, &objects);
(d, sid, fid)
}
/// Baselines of the last line above the top of the first wrap and of the first line below it.
fn around_wrap(d: &Document, sid: StoryId) -> (Option<f64>, f64) {
let specs = frame_specs(d, sid);
let ex = &specs[0].exclusions[0];
let cs = compose_story(d, sid, &ComposeOptions::default());
let lines = &cs.frames[0].lines;
let above = lines.iter().rev().find(|l| l.baseline <= ex.rect.y0 + 1e-6).map(|l| l.baseline);
(above, first_line_below(&cs, ex).baseline)
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 0.01
}
#[test]
fn jump_object_moves_the_next_line_down_by_whole_leadings() {
// As measured: last baseline above 386.41, wrap bottom 472.77 → 494.40 (6 leadings). Here the last
// line above is at 386.36 and the wrap ends 86.36 below it.
let text = [LOREM; 8].join(" ");
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 395.0, 200.0, 472.72), [0.0; 4])]);
let (above, below) = around_wrap(&d, sid);
assert!(near(above.unwrap(), 386.36), "{above:?}");
assert!(near(below, 386.36 + 6.0 * 18.0), "{below}");
// The lines after it keep the leading.
let cs = compose_story(&d, sid, &ComposeOptions::default());
let next = cs.frames[0].lines.iter().find(|l| l.baseline > below + 1.0).unwrap();
assert!(near(next.baseline, below + 18.0));
}
#[test]
fn jump_object_clears_the_wrap_with_the_leading_not_the_ascent() {
// As measured: last 152.38, wrap bottom 246.90 → 278.38. Clearing the wrap with the line's ascent
// instead of its leading would put the line one leading higher (here the wrap ends a little higher,
// as the test font's ascent is larger).
let text = [LOREM; 8].join(" ");
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 160.0, 324.0, 244.0), [0.0; 4])]);
let (above, below) = around_wrap(&d, sid);
assert!(near(above.unwrap(), 152.36), "{above:?}");
assert!(near(below, 152.36 + 7.0 * 18.0), "{below}");
let cs = compose_story(&d, sid, &ComposeOptions::default());
let asc = cs.frames[0].lines[0].ascent;
let by_ascent = (1..).map(|k| 152.36 + k as f64 * 18.0).find(|b| b - asc >= 244.0).unwrap();
assert!(!near(by_ascent, below), "the case must tell the two rules apart ({asc})");
}
#[test]
fn jump_object_adds_space_before_once() {
// As measured: a heading at 78.36, then a paragraph with 60 pt space before, wrap bottom 439.71 →
// 462.36 = 78.36 + 60 + 18 × 18.
let text = format!("Heading\n{}", [LOREM; 6].join(" "));
let (mut d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 90.0, 324.0, 439.71), [0.0; 4])]);
d.story_mut(sid).unwrap().format_paras(10..10, |p| p.para.space_before = Some(60.0));
let (above, below) = around_wrap(&d, sid);
assert!(near(above.unwrap(), 80.36), "{above:?}");
assert!(near(below, 80.36 + 60.0 + 18.0 * 18.0), "{below}");
}
#[test]
fn jump_object_at_the_frame_top_starts_text_like_a_frame_top() {
// As measured: wrap bottom 266.83 → first baseline 284.835 (Leading offset: one leading below).
let text = [LOREM; 8].join(" ");
let (mut d, sid, fid) = jump_doc(&text, &[(Rect::new(36.0, 50.0, 324.0, 266.83), [0.0; 4])]);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = &cs.frames[0].lines;
assert!(near(lines[0].baseline, 266.83 + 18.0), "{}", lines[0].baseline);
assert!(near(lines[1].baseline, 266.83 + 36.0), "{}", lines[1].baseline);
// Ascent offset: the line's ascent below the wrap.
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Ascent;
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
assert!(near(l.baseline, 266.83 + l.ascent), "{} vs {}", l.baseline, l.ascent);
}
#[test]
fn line_above_jump_object_stays_while_its_baseline_is_above_the_wrap() {
let text = [LOREM; 8].join(" ");
// The line at 386.36 hangs its descenders into the object and stays.
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 386.36, 324.0, 420.0), [0.0; 4])]);
let (above, below) = around_wrap(&d, sid);
assert!(near(above.unwrap(), 386.36), "{above:?}");
assert!(near(below, 440.36), "{below}");
// A wrap starting just above its baseline makes it jump.
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 386.3, 324.0, 420.0), [0.0; 4])]);
let (above, below) = around_wrap(&d, sid);
assert!(near(above.unwrap(), 368.36), "{above:?}");
assert!(near(below, 440.36), "{below}");
}
#[test]
fn negative_jump_offsets_shrink_the_wrap() {
let text = [LOREM; 8].join(" ");
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 300.0, 324.0, 500.0), [-20.0, 0.0, -30.0, 0.0])]);
let specs = frame_specs(&d, sid);
let r = specs[0].exclusions[0].rect;
assert!(near(r.y0, 320.0) && near(r.y1, 470.0), "{r:?}");
let (above, below) = around_wrap(&d, sid);
assert!(near(above.unwrap(), 314.36), "{above:?}");
assert!(near(below, 494.36), "{below}");
}
#[test]
fn jump_object_down_to_the_frame_bottom_sends_text_to_the_next_frame() {
let text = [LOREM; 8].join(" ");
let (mut d, sid, a) = jump_doc(&text, &[(Rect::new(36.0, 300.0, 324.0, 540.0), [0.0; 4])]);
let lid = d.default_layer();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(350.0, 62.36, 550.0, 532.91), lid, "", ParaFormat::default()).unwrap();
d.item_mut(b).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading;
d.thread(a, b).unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let last = cs.frames[0].lines.last().unwrap();
assert!(near(last.baseline, 296.36), "{}", last.baseline);
assert!(near(cs.frames[1].lines[0].baseline, 62.36 + 18.0), "{}", cs.frames[1].lines[0].baseline);
assert_eq!(cs.frames[0].range.end, cs.frames[1].range.start);
}
#[test]
fn full_width_bounding_box_wrap_resumes_exactly_below() {
// A bounding-box wrap across the whole column leaves no slot: the line jumps it like Jump Object.
let (d, sid) = wrap_doc(ParaAttrs::default(), &[(Rect::new(20.0, 100.0, 340.0, 150.25), [0.0; 4], WrapMode::BoundingBox)]);
let specs = frame_specs(&d, sid);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let ex = &specs[0].exclusions[0];
let l = first_line_below(&cs, ex);
assert!((l.baseline - l.ascent - ex.rect.y1).abs() < 0.01, "{} vs {}", l.baseline - l.ascent, ex.rect.y1);
// A slot narrower than 1.5 × the size is no slot either.
let (d, sid) = wrap_doc(ParaAttrs::default(), &[(Rect::new(50.0, 100.0, 340.0, 150.25), [0.0; 4], WrapMode::BoundingBox)]);
let specs = frame_specs(&d, sid);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let ex = &specs[0].exclusions[0];
let l = first_line_below(&cs, ex);
assert!((l.baseline - l.ascent - ex.rect.y1).abs() < 0.01, "{} vs {}", l.baseline - l.ascent, ex.rect.y1);
}
#[test]
fn jump_object_keeps_grid_aligned_lines_on_the_grid() {
// The wrap ends at 166.5, off the 12 pt grid; a second object blocks the grid line the line jumps
// to, so the line moves on to the grid line after that object.
let objects = [
(Rect::new(36.0, 100.0, 200.0, 160.5), [0.0, 0.0, 6.0, 0.0], WrapMode::JumpObject),
(Rect::new(250.0, 178.0, 324.0, 190.0), [0.0; 4], WrapMode::JumpObject),
];
let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &objects);
let specs = frame_specs(&d, sid);
let (g0, inc) = specs[0].grid.unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let on_grid = |b: f64| (((b - g0) / inc).round() * inc + g0 - b).abs() < 1e-6;
let lines = &cs.frames[0].lines;
assert!(lines.iter().all(|l| on_grid(l.baseline)), "{:?}", lines.iter().map(|l| l.baseline).collect::<Vec<_>>());
let exs = &specs[0].exclusions;
let l = first_line_below(&cs, &exs[0]);
// Naive oracle: the first grid line whose leading band clears the objects.
let clear = |b: f64, exs: &[Exclusion]| exs.iter().all(|e| b <= e.rect.y0 || b - l.leading >= e.rect.y1);
let want = |exs: &[Exclusion]| (0..).map(|n| g0 + n as f64 * inc).find(|&b| b > exs[0].rect.y0 && clear(b, exs)).unwrap();
assert!((l.baseline - want(exs)).abs() < 1e-6, "baseline {} should be on grid line {}", l.baseline, want(exs));
assert!(want(&exs[..1]) < want(exs), "the second object pushed the line again");
// First line only: a later line of the paragraph jumps by whole leadings from the line above.
let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, &objects[..1]);
let specs = frame_specs(&d, sid);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let ex = &specs[0].exclusions[0];
let l = first_line_below(&cs, ex);
assert!(!l.first_in_para);
let above = cs.frames[0].lines.iter().rev().find(|a| a.baseline <= ex.rect.y0).unwrap().baseline;
let want = (1..).map(|k| above + k as f64 * 12.0).find(|b| b - 12.0 >= ex.rect.y1).unwrap();
assert!((l.baseline - want).abs() < 1e-6, "{} vs {want}", l.baseline);
}
#[test]
fn hostile_wrap_geometry_terminates() {
let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &[]);
let mut specs = frame_specs(&d, sid);
let ex = |x0: f64, y0: f64, x1: f64, y1: f64, mode| Exclusion { rect: Rect::new(x0, y0, x1, y1), mode };
let f = &mut specs[0];
f.exclusions.push(ex(f64::NAN, f64::NAN, f64::NAN, f64::NAN, WrapMode::JumpObject));
f.exclusions.push(ex(-1e308, 300.0, 1e308, f64::INFINITY, WrapMode::JumpObject));
f.exclusions.push(ex(0.0, f64::NEG_INFINITY, 400.0, 40.0, WrapMode::BoundingBox));
// A wall of overlapping strips, each ending 0.25 pt below the previous.
for i in 0..3000 {
let y = 60.0 + i as f64 * 0.25;
f.exclusions.push(ex(0.0, y, 400.0, y + 1.0, if i % 2 == 0 { WrapMode::JumpObject } else { WrapMode::BoundingBox }));
}
f.grid = Some((0.0, 1e-300));
let story = d.story(sid).unwrap();
let cs = compose(&d, story, &specs, &ComposeOptions::default());
assert!(cs.is_overset());
for l in &cs.frames[0].lines {
assert!(l.baseline.is_finite() && l.baseline - l.ascent >= 40.0 && l.baseline + l.descent <= 300.0, "{}", l.baseline);
}
}
#[test]
fn paragraph_composer_is_no_worse_than_greedy() {
// Sum of squared slack over lines (excluding last) should not exceed the greedy result.
@@ -357,7 +592,11 @@ fn keep_lines_together_moves_the_paragraph() {
let (mut d, sid, _) = keep_doc(n - 3, &[&long], h);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(column_of_para(&cs, n - 3).contains(&0));
d.story_mut(sid).unwrap().paras[n - 3].para.keep_lines_together = Some(true);
{
let p = &mut d.story_mut(sid).unwrap().paras[n - 3].para;
p.keep_lines_together = Some(true);
p.keep_all_lines = Some(true);
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(column_of_para(&cs, n - 3).iter().all(|&c| c == 1));
}
@@ -1260,6 +1499,594 @@ fn satisfiable_keep_chain_still_moves_together_to_the_next_frame() {
assert_eq!(cs.frame(second).unwrap().lines.iter().map(|l| l.para).collect::<Vec<_>>(), [1, 2, 3]);
}
/// OS/2 field offsets: sTypoAscender, sxHeight, sCapHeight.
const TYPO_ASCENDER: usize = 68;
const X_HEIGHT: usize = 86;
const CAP_HEIGHT: usize = 88;
/// Source Sans 3 Regular (1000 units per em; OS/2 typo ascender = hhea ascender = 1000, cap height
/// 660, x height 486) renamed to `family` (13 characters, the length of "Source Sans 3"), with the
/// OS/2 fields at the given offsets set, or without an OS/2 table for `None`.
fn test_font(family: &str, os2_fields: Option<&[(usize, i16)]>) -> Vec<u8> {
let mut b = designcraft_fonts::bundled()[0].to_vec();
assert_eq!(family.len(), "Source Sans 3".len());
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
let mut i = 0;
while let Some(p) = b[i..].windows(from.len()).position(|w| w == from.as_slice()) {
b[i + p..i + p + to.len()].copy_from_slice(&to);
i += p + to.len();
}
}
let tables = u16::from_be_bytes([b[4], b[5]]) as usize;
let rec = (0..tables).map(|t| 12 + 16 * t).find(|&r| &b[r..r + 4] == b"OS/2").unwrap();
match os2_fields {
Some(fields) => {
let os2 = u32::from_be_bytes(b[rec + 8..rec + 12].try_into().unwrap()) as usize;
for &(at, v) in fields {
b[os2 + at..os2 + at + 2].copy_from_slice(&v.to_be_bytes());
}
}
// Still sorted between its neighbours, so the table directory stays valid.
None => b[rec..rec + 4].copy_from_slice(b"OS/1"),
}
b
}
/// [`test_font`] with OS/2 `sTypoAscender` set to `typo_ascender`, or without OS/2 for `None`.
fn typo_test_font(family: &str, typo_ascender: Option<i16>) -> Vec<u8> {
test_font(family, typo_ascender.map(|v| [(TYPO_ASCENDER, v)]).as_ref().map(|f| f.as_slice()))
}
/// First baseline (frame space) of a 20 pt line in `family` in a frame at y = 36 with a 4 pt top
/// inset, set up by `opts`.
fn first_baseline_in(family: &str, opts: impl Fn(&mut designcraft_doc::TextFrameOptions)) -> f64 {
first_baseline_in_dir(family, false, opts)
}
/// [`first_baseline_in`] in a vertical story when `vertical`.
fn first_baseline_in_dir(family: &str, vertical: bool, opts: impl Fn(&mut designcraft_doc::TextFrameOptions)) -> f64 {
let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..7, |f| {
f.over.font_family = Some(family.into());
f.over.font_style = Some("Regular".into());
f.over.size = Some(20.0);
});
d.story_mut(sid).unwrap().vertical = vertical;
let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap();
tf.options.inset = [4.0, 0.0, 0.0, 0.0];
opts(&mut tf.options);
let cs = compose_story(&d, sid, &ComposeOptions::default());
cs.frames[0].lines[0].baseline
}
#[test]
fn ascent_first_baseline_uses_the_typographic_ascender() {
use designcraft_doc::FirstBaseline;
let db = designcraft_fonts::FontDb::global();
db.add_font(typo_test_font("TypoAscent 07", Some(700)));
let face = db.face("TypoAscent 07", "Regular");
assert_eq!(face.family, "TypoAscent 07");
assert_eq!((face.ascent, face.typo_ascent), (1000.0, 700.0), "hhea 1 em, typo 0.7 em");
let top = 36.0 + 4.0;
let at = |kind: FirstBaseline, min: f64| {
first_baseline_in("TypoAscent 07", |o| {
o.first_baseline = kind;
o.first_baseline_min = min;
})
};
let close = |a: f64, b: f64| (a - b).abs() < 0.01;
// Ascent: top + typo ascender (0.7 em of 20 pt), not the hhea ascender (1 em).
assert!(close(at(FirstBaseline::Ascent, 0.0), top + 14.0), "{}", at(FirstBaseline::Ascent, 0.0));
// The minimum offset still wins when it is larger.
assert!(close(at(FirstBaseline::Ascent, 18.0), top + 18.0));
assert!(close(at(FirstBaseline::Ascent, 10.0), top + 14.0));
// The other kinds don't use it.
assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 0.660 * 20.0));
assert!(close(at(FirstBaseline::XHeight, 0.0), top + 0.486 * 20.0));
assert!(close(at(FirstBaseline::Leading, 0.0), top + 24.0));
assert!(close(at(FirstBaseline::Fixed, 0.0), top));
// Vertical scale scales it like the ascent.
let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..3, |f| {
f.over.font_family = Some("TypoAscent 07".into());
f.over.size = Some(20.0);
f.over.v_scale = Some(1.5);
});
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0];
let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0];
assert!(close(l.baseline, top + 21.0), "{}", l.baseline);
// The line box keeps the font's ascent.
assert!(close(l.ascent, 30.0), "{}", l.ascent);
// A mixed first line takes its tallest typographic ascender: 0.7 × 20 pt beats 1 em × 10 pt …
let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
let st = d.story_mut(sid).unwrap();
st.format_chars(0..3, |f| {
f.over.font_family = Some("TypoAscent 07".into());
f.over.size = Some(20.0);
});
st.format_chars(3..7, |f| {
f.over.font_family = Some("Source Sans 3".into());
f.over.size = Some(10.0);
});
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0];
let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline;
assert!(close(first(&d), top + 14.0), "{}", first(&d));
// … and 1 em × 16 pt beats it.
d.story_mut(sid).unwrap().format_chars(3..7, |f| f.over.size = Some(16.0));
assert!(close(first(&d), top + 16.0), "{}", first(&d));
}
#[test]
fn ascent_first_baseline_falls_back_to_the_ascent_without_a_usable_typo_ascender() {
let db = designcraft_fonts::FontDb::global();
// No OS/2 table, a negative and an absurd typo ascender: the (hhea) ascent, 1 em.
for (family, typo) in [("TypoAscentNoO", None), ("TypoAscentNeg", Some(-300)), ("TypoAscentBig", Some(i16::MAX))] {
db.add_font(typo_test_font(family, typo));
let face = db.face(family, "Regular");
assert_eq!(face.family, family);
assert_eq!(face.typo_ascent, face.ascent, "{family}");
let b = first_baseline_in(family, |_| {});
assert!((b - (36.0 + 4.0 + 20.0)).abs() < 0.01, "{family}: {b}");
}
}
#[test]
fn vertical_frames_keep_the_ascent_first_baseline() {
let db = designcraft_fonts::FontDb::global();
db.add_font(typo_test_font("TypoAscentVrt", Some(700)));
let b = first_baseline_in_dir("TypoAscentVrt", true, |o| {
o.inset = [0.0; 4];
});
// Composed in the turned box (no inset): the hhea ascent, 1 em of 20 pt.
assert!((b - 20.0).abs() < 0.01, "{b}");
}
#[test]
fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() {
use designcraft_doc::FirstBaseline;
let db = designcraft_fonts::FontDb::global();
db.add_font(test_font("CapXHeight 01", Some(&[(CAP_HEIGHT, 500), (X_HEIGHT, 300)])));
let face = db.face("CapXHeight 01", "Regular");
assert_eq!(face.family, "CapXHeight 01");
assert_eq!((face.ascent, face.cap_height, face.x_height), (1000.0, 500.0, 300.0));
let top = 36.0 + 4.0;
let at = |kind: FirstBaseline, min: f64| {
first_baseline_in("CapXHeight 01", |o| {
o.first_baseline = kind;
o.first_baseline_min = min;
})
};
let close = |a: f64, b: f64| (a - b).abs() < 0.01;
// Cap height 0.5 em and x height 0.3 em of 20 pt, not 0.72 / 0.5 of the 1 em ascent.
assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 10.0), "{}", at(FirstBaseline::CapHeight, 0.0));
assert!(close(at(FirstBaseline::XHeight, 0.0), top + 6.0), "{}", at(FirstBaseline::XHeight, 0.0));
// The minimum offset still wins when it is larger.
assert!(close(at(FirstBaseline::CapHeight, 12.0), top + 12.0));
assert!(close(at(FirstBaseline::XHeight, 5.0), top + 6.0));
// Vertical scale and a raised baseline count, as for the ascent.
let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
let st = d.story_mut(sid).unwrap();
st.format_chars(0..7, |f| {
f.over.font_family = Some("CapXHeight 01".into());
f.over.size = Some(20.0);
f.over.v_scale = Some(1.5);
});
st.format_chars(4..5, |f| f.over.baseline_shift = Some(1.0));
let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap();
tf.options.inset = [4.0, 0.0, 0.0, 0.0];
tf.options.first_baseline = FirstBaseline::CapHeight;
let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline;
assert!(close(first(&d), top + 15.0 + 1.0), "{}", first(&d));
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::XHeight;
assert!(close(first(&d), top + 9.0 + 1.0), "{}", first(&d));
}
#[test]
fn cap_height_and_x_height_fall_back_without_usable_os2_values() {
use designcraft_doc::FirstBaseline;
let db = designcraft_fonts::FontDb::global();
// No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and 0.5 of the
// (1 em) ascent.
let zero: &[(usize, i16)] = &[(CAP_HEIGHT, 0), (X_HEIGHT, -40)];
let big: &[(usize, i16)] = &[(CAP_HEIGHT, i16::MAX), (X_HEIGHT, i16::MAX)];
for (family, fields) in [("CapXHeightNoO", None), ("CapXHeightNeg", Some(zero)), ("CapXHeightBig", Some(big))] {
db.add_font(test_font(family, fields));
let face = db.face(family, "Regular");
assert_eq!(face.family, family);
assert_eq!((face.cap_height, face.x_height), (656.0, face.ascent * 0.5), "{family}");
let cap = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::CapHeight);
let x = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::XHeight);
assert!((cap - (40.0 + 13.12)).abs() < 0.01 && (x - (40.0 + 10.0)).abs() < 0.01, "{family}: {cap} {x}");
}
}
#[test]
fn vertical_frames_keep_the_cap_height_and_x_height_first_baselines() {
use designcraft_doc::FirstBaseline;
let db = designcraft_fonts::FontDb::global();
db.add_font(test_font("CapXHeightVrt", Some(&[(CAP_HEIGHT, 500), (X_HEIGHT, 300)])));
let at = |kind: FirstBaseline| {
first_baseline_in_dir("CapXHeightVrt", true, |o| {
o.first_baseline = kind;
o.inset = [0.0; 4];
})
};
// 0.72 and 0.5 of the 1 em ascent of 20 pt.
assert!((at(FirstBaseline::CapHeight) - 14.4).abs() < 0.01, "{}", at(FirstBaseline::CapHeight));
assert!((at(FirstBaseline::XHeight) - 10.0).abs() < 0.01, "{}", at(FirstBaseline::XHeight));
}
// ---------- drop caps ----------
/// `text` set 10 pt on 12 pt leading in a 300 pt measure, with `para` on top.
fn drop_doc(text: &str, para: ParaAttrs) -> (Document, StoryId, ItemId) {
drop_doc_in(text, Rect::new(0.0, 0.0, 300.0, 1000.0), para)
}
fn drop_doc_in(text: &str, rect: Rect, para: ParaAttrs) -> (Document, StoryId, ItemId) {
let (mut d, sid, fid) = doc_with(text, rect, para);
for p in &mut d.story_mut(sid).unwrap().paras {
p.chars = designcraft_doc::CharAttrs { size: Some(10.0), leading: Some(designcraft_doc::Leading::Points(12.0)), ..Default::default() };
}
(d, sid, fid)
}
fn drop_cap(lines: u32, chars: u32) -> ParaAttrs {
ParaAttrs { drop_cap_lines: Some(lines), drop_cap_chars: Some(chars), ..Default::default() }
}
/// Cap height of a placed glyph, in points.
fn cap_of(g: &PlacedGlyph) -> f64 {
g.face.cap_height * g.sy
}
/// x of the first glyph of `l` set from story byte `from` on.
fn text_x(l: &Line, from: usize) -> f64 {
l.glyphs.iter().find(|g| g.len > 0 && g.byte >= from).map(|g| g.x).unwrap()
}
#[test]
fn drop_cap_spans_lines_and_indents_them() {
let text = [LOREM; 2].join(" ");
let (plain, psid, _) = drop_doc(&text, ParaAttrs::default());
let plain = compose_story(&plain, psid, &ComposeOptions::default());
let (d, sid, _) = drop_doc(&text, drop_cap(3, 1));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let pl = all_lines(&plain);
assert!(lines.len() > 4);
// Baselines don't move.
for (k, l) in lines.iter().enumerate() {
assert!((l.baseline - (pl[0].baseline + 12.0 * k as f64)).abs() < 1e-6, "line {k} at {}", l.baseline);
}
// The drop cap: the first glyph, on line 1, its baseline on line 3's and its cap top on line 1's.
let dc = &lines[0].glyphs[0];
assert_eq!((dc.byte, dc.len), (0, 1));
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "drop cap baseline {}", lines[0].baseline + dc.y);
let body = lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap();
let top = lines[2].baseline - cap_of(dc);
assert!((top - (lines[0].baseline - cap_of(body))).abs() < 0.01, "cap top {top}");
assert!(dc.sy > body.sy * 3.0);
// Lines 1–3 start after it, line 4 at the frame edge.
let right = dc.x + dc.adv;
assert!(right > 20.0);
for l in &lines[..3] {
assert!(text_x(l, 1) >= right - 1e-6, "{} < {right}", text_x(l, 1));
assert!(l.end_x <= l.x1 + 0.5);
}
assert!(text_x(lines[3], 1) < 0.5);
// The text stays in order: the drop cap starts line 1 (exports and caret read it there).
assert_eq!(lines[0].range.start, 0);
}
#[test]
fn drop_cap_of_two_characters_as_a_local_override() {
let (d, sid, _) = drop_doc(LOREM, drop_cap(2, 2));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let (a, b) = (&lines[0].glyphs[0], &lines[0].glyphs[1]);
assert_eq!((a.byte, b.byte), (0, 1));
let body = lines[0].glyphs.iter().find(|g| g.byte == 2).unwrap();
for g in [a, b] {
assert!((lines[0].baseline + g.y - lines[1].baseline).abs() < 1e-6);
assert!(g.sy > body.sy * 1.5);
}
assert!((b.x - (a.x + a.adv)).abs() < 1e-6, "the drop cap characters sit side by side");
let right = b.x + b.adv;
assert!(text_x(lines[0], 2) >= right - 1e-6 && text_x(lines[1], 2) >= right - 1e-6);
assert!(text_x(lines[2], 2) < 0.5);
}
#[test]
fn short_paragraph_keeps_its_drop_cap_and_the_next_paragraph_is_not_indented() {
let (mut d, sid, _) = drop_doc(&format!("Short.\n{LOREM}"), drop_cap(3, 1));
d.story_mut(sid).unwrap().paras[1].para = ParaAttrs::default();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert_eq!(lines.iter().filter(|l| l.para == 0).count(), 1);
let dc = &lines[0].glyphs[0];
// Still three lines tall (it may overlap the next paragraph, which isn't indented).
assert!((dc.y - 24.0).abs() < 1e-6, "{}", dc.y);
assert!(text_x(lines[1], 7) < 0.5);
}
#[test]
fn drop_cap_counts_code_points_without_splitting_clusters_and_clamps_to_the_paragraph() {
// Four characters: a letter, its combining mark, another letter and a no-break space.
let (d, sid, _) = drop_doc("A\u{301}B\u{a0}rest of the words", drop_cap(2, 4));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
let big: Vec<usize> = l.glyphs.iter().filter(|g| g.len > 0 && g.y > 1.0).map(|g| g.byte).collect();
assert!(big.contains(&0) && big.contains(&3), "{big:?}");
assert!(big.iter().all(|b| *b < 6), "the r isn't part of it: {big:?}");
// One character that has a combining mark after it takes the mark too.
let (d, sid, _) = drop_doc("e\u{301}tude and more words", drop_cap(2, 1));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
let big: Vec<usize> = l.glyphs.iter().filter(|g| g.y > 1.0).map(|g| g.byte).collect();
assert!(!big.is_empty() && big.iter().all(|b| *b < 3), "{big:?}");
assert!(l.glyphs.iter().any(|g| g.byte == 0 && g.y > 1.0));
// More characters than the paragraph has: all of them, and nothing of the next paragraph.
let (mut d, sid, _) = drop_doc("Hi\nNext", drop_cap(2, 50));
d.story_mut(sid).unwrap().paras[1].para = ParaAttrs::default();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert!(lines[0].glyphs.iter().filter(|g| g.len > 0).all(|g| g.y > 1.0));
assert!(lines[1].glyphs.iter().all(|g| g.y.abs() < 1e-6));
}
#[test]
fn hostile_drop_cap_values_do_not_panic() {
for (lines, chars) in [(u32::MAX, u32::MAX), (1_000_000, 1), (2, 0), (0, 3)] {
let (d, sid, _) = drop_doc(LOREM, drop_cap(lines, chars));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let g = &cs.frames[0].lines[0].glyphs[0];
assert!(g.y.is_finite() && g.sy.is_finite() && g.y <= 12.0 * 25.0, "{lines}/{chars}: {}", g.y);
}
// No lines or no characters: no drop cap.
let (d, sid, _) = drop_doc(LOREM, drop_cap(0, 3));
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(cs.frames[0].lines[0].glyphs[0].y.abs() < 1e-6);
}
#[test]
fn one_line_drop_cap_is_not_enlarged() {
let (d, sid, _) = drop_doc(LOREM, drop_cap(1, 1));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
let (dc, body) = (&l.glyphs[0], l.glyphs.iter().find(|g| g.byte == 1).unwrap());
assert!((cap_of(dc) - cap_of(body)).abs() < 0.01 && dc.y.abs() < 1e-6);
}
#[test]
fn drop_cap_stays_with_the_first_line_across_a_column_break() {
// Two lines fit a column: line 3 starts the second column, at its edge.
let (mut d, sid, fid) = drop_doc_in(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 400.0, 30.0), drop_cap(3, 1));
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
let cs = compose_story(&d, sid, &ComposeOptions::default());
let ft = &cs.frames[0];
let col1: Vec<&Line> = ft.lines.iter().filter(|l| l.column == 1).collect();
assert_eq!(ft.lines.iter().filter(|l| l.column == 0).count(), 2, "{:?}", ft.lines.iter().map(|l| l.column).collect::<Vec<_>>());
let dc = &ft.lines[0].glyphs[0];
assert!((dc.y - 24.0).abs() < 1e-6, "{}", dc.y);
assert!(text_x(&ft.lines[1], 1) >= dc.x + dc.adv - 1e-6);
assert!((text_x(col1[0], 1) - ft.columns[1].x0).abs() < 0.5);
}
#[test]
fn drop_cap_in_right_to_left_paragraphs_is_on_the_right() {
let para = ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::Right), ..drop_cap(2, 1) };
let (d, sid, _) = drop_doc(LOREM, para);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let dc = lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap();
assert!((dc.x + dc.adv - 300.0).abs() < 0.5, "{}", dc.x + dc.adv);
for l in &lines[..2] {
assert!(l.glyphs.iter().filter(|g| g.byte > 0 && g.visible).all(|g| g.x + g.adv <= dc.x + 0.5));
}
assert!(lines[2].end_x > 299.0 || lines[2].glyphs.iter().any(|g| g.x + g.adv > 299.0));
}
#[test]
fn vertical_frames_set_no_drop_cap() {
let (mut d, sid, _) = drop_doc(LOREM, drop_cap(3, 1));
d.story_mut(sid).unwrap().vertical = true;
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(cs.frames[0].lines[0].glyphs[0].y.abs() < 1e-6);
}
#[test]
fn drop_cap_takes_its_character_style_and_nested_styles_count_it() {
let (mut d, sid, _) = drop_doc(LOREM, ParaAttrs { drop_cap_style: Some("Initial".into()), ..drop_cap(2, 3) });
for (name, fill) in [("Initial", "Initial Red"), ("Lead", "Lead Blue")] {
std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle {
name: name.into(),
based_on: None,
chars: designcraft_doc::CharAttrs { fill: Some(fill.into()), ..Default::default() },
shortcut: String::new(),
});
}
let fill = |cs: &ComposedStory, g: &PlacedGlyph| cs.styles[g.style as usize].fill.clone();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap();
assert_eq!(fill(&cs, at(0)), "Initial Red");
assert_eq!(fill(&cs, at(2)), "Initial Red");
assert_eq!(fill(&cs, at(3)), "[Black]");
// A nested style "through 1 drop cap" covers the drop cap; the next one starts after it.
let ns = |style: &str, until: designcraft_doc::NestedUntil| designcraft_doc::NestedStyle { style: style.into(), through: true, count: 1, until };
d.story_mut(sid).unwrap().paras[0].para = ParaAttrs {
nested_styles: Some(vec![ns("Lead", designcraft_doc::NestedUntil::Dropcap), ns("Initial", designcraft_doc::NestedUntil::Words)]),
..drop_cap(2, 3)
};
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap();
assert_eq!(fill(&cs, at(0)), "Lead Blue");
assert_eq!(fill(&cs, at(2)), "Lead Blue");
assert_eq!(fill(&cs, at(3)), "Initial Red", "the second nested style starts after the drop cap");
let first_space = LOREM.find(' ').unwrap();
assert_eq!(fill(&cs, at(first_space + 1)), "[Black]");
}
#[test]
fn drop_cap_caret_and_hit_testing() {
let (d, sid, _) = drop_doc(LOREM, drop_cap(3, 1));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let dc = lines[0].glyphs[0].clone();
let drop_box = lines[0].drop_cap.unwrap().rect;
assert!(drop_box.y1 > lines[2].baseline - 0.01 && drop_box.y0 < lines[0].baseline - 5.0, "{drop_box:?}");
// A click on the lower half of the big letter (beside line 3) is at the drop cap.
let y = lines[2].baseline - 2.0;
assert_eq!(hit(&cs, 0, Point::new(dc.x + dc.adv * 0.25, y)), Some(0));
assert_eq!(hit(&cs, 0, Point::new(dc.x + dc.adv * 0.75, y)), Some(1));
// A double click there is on the drop cap's character, not on line 3's text.
assert_eq!(hit_char(&cs, 0, Point::new(dc.x + dc.adv * 0.5, y)), Some(0));
// The caret before the drop cap is as tall as it; after it, beside line 1's text.
let (_, x, bl, asc, _) = caret(&cs, 0).unwrap();
assert!((x - dc.x).abs() < 1e-6 && (bl - lines[2].baseline).abs() < 1e-6 && asc > 24.0, "{x} {bl} {asc}");
let (_, x, bl, _, _) = caret(&cs, 1).unwrap();
assert!(x >= dc.x + dc.adv - 1e-6 && (bl - lines[0].baseline).abs() < 1e-6);
}
#[test]
fn drop_cap_sits_on_its_line_as_set() {
// Lines on a 15 pt baseline grid (not the 12 pt leading): the drop cap follows line 3.
let (mut d, sid, _) = drop_doc(LOREM, ParaAttrs { grid_align: Some(designcraft_doc::GridAlign::AllLines), ..drop_cap(3, 1) });
d.settings.baseline_grid.increment = 15.0;
d.settings.baseline_grid.start = 0.0;
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert!((lines[1].baseline - lines[0].baseline - 15.0).abs() < 1e-6);
let dc = &lines[0].glyphs[0];
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6);
let b = lines[0].drop_cap.unwrap();
assert!((b.baseline - lines[2].baseline).abs() < 1e-6 && b.rect.y1 > lines[2].baseline);
// Scaled to the grid's line pitch: its cap top is still line 1's.
let body = lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap();
assert!((lines[2].baseline - cap_of(dc) - (lines[0].baseline - cap_of(body))).abs() < 0.01);
}
/// A rule in Text Color takes the colour of the paragraph's text: the first character's for the
/// rule above, the last character's for the rule below. A swatch colour is used as is.
#[test]
fn text_color_rules_follow_the_text() {
use designcraft_doc::{CharAttrs, CharFormat, Rule, TEXT_COLOR};
let rule = Rule { on: true, weight: 2.0, ..Default::default() };
let para = ParaAttrs { rule_above: Some(rule.clone()), rule_below: Some(rule.clone()), ..Default::default() };
let (mut d, sid, fid) = doc_with("Red then blue", Rect::new(0.0, 0.0, 300.0, 200.0), para);
{
let s = d.story_mut(sid).unwrap();
s.format_chars(0..3, |f: &mut CharFormat| f.over.merge(&CharAttrs { fill: Some("Red".into()), fill_tint: Some(0.5), ..Default::default() }));
s.format_chars(9..13, |f: &mut CharFormat| f.over.fill = Some("Blue".into()));
}
let decos = |d: &Document| {
let cs = compose_story(d, sid, &ComposeOptions::default());
let mut v: Vec<(f64, String, f32)> = cs.frame(fid).unwrap().decos.iter().map(|dc| (dc.rect.y0, dc.color.clone(), dc.tint)).collect();
v.sort_by(|a, b| a.0.total_cmp(&b.0));
v.into_iter().map(|(_, c, t)| (c, t)).collect::<Vec<_>>()
};
assert_eq!(rule.color, TEXT_COLOR);
assert_eq!(decos(&d), vec![("Red".to_string(), 0.5), ("Blue".to_string(), 1.0)]);
let swatch = Rule { color: "[Black]".into(), tint: 0.3, ..rule };
d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(swatch);
assert_eq!(decos(&d)[0], ("[Black]".to_string(), 0.3));
}
/// Align Left Edge (InDesign's default): the drop cap's origin moves left by its first letter's
/// left side bearing so its ink starts at the indent, and the lines beside it start where its
/// advance ends. Measured case: 14/18 text, two lines, one character.
#[test]
fn drop_cap_aligns_its_left_edge() {
let text = format!("Once {LOREM}");
let compose = |align: Option<bool>| {
let para = ParaAttrs { left_indent: Some(10.0), drop_cap_align_left: align, ..drop_cap(2, 1) };
let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para);
d.story_mut(sid).unwrap().paras[0].chars =
designcraft_doc::CharAttrs { size: Some(14.0), leading: Some(designcraft_doc::Leading::Points(18.0)), ..Default::default() };
compose_story(&d, sid, &ComposeOptions::default())
};
use designcraft_geom::Shape as _;
let ink_left = |g: &PlacedGlyph| g.x + designcraft_fonts::FontDb::global().outline(g.face.get(), g.gid).bounding_box().x0 * g.sx;
for align in [None, Some(true), Some(false)] {
let cs = compose(align);
let lines = all_lines(&cs);
let dc = &lines[0].glyphs[0];
assert_eq!(dc.byte, 0);
let lsb = ink_left(dc) - dc.x;
assert!(lsb > 0.5, "the O has a visible side bearing at this size: {lsb}");
if align != Some(false) {
assert!((ink_left(dc) - 10.0).abs() < 1e-6, "{align:?}: ink at {}", ink_left(dc));
} else {
assert!((dc.x - 10.0).abs() < 1e-6, "origin at {}", dc.x);
}
for l in &lines[..2] {
assert!((text_x(l, 1) - (dc.x + dc.adv)).abs() < 1e-6, "{align:?}: {} vs {}", text_x(l, 1), dc.x + dc.adv);
}
assert!((text_x(lines[2], 1) - 10.0).abs() < 1e-6);
}
}
#[test]
fn caret_follows_spaces_typed_at_the_end() {
let x_at_end = |text: &str| {
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
let end_x = cs.frames[0].lines[0].end_x;
(caret(&cs, text.len()).unwrap().1, end_x)
};
let (word, word_end) = x_at_end("Word");
let (one, one_end) = x_at_end("Word ");
let (two, _) = x_at_end("Word ");
assert!(one > word + 1.0 && two > one + 1.0, "{word} {one} {two}");
// The spaces don't count for the line's own length (alignment, justification).
assert!((one_end - word_end).abs() < 1e-9);
// Wrapped lines keep their spaces too: a caret between two spaces at a line end sits after
// the first one.
let text = "Lorem ipsum dolor sit amet consectetur";
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 140.0, 100.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l0 = &cs.frames[0].lines[0];
if l0.range.end == text.find("consectetur").unwrap() {
let mid = text.find(" ").unwrap() + 1;
assert!(caret(&cs, mid).unwrap().1 > l0.end_x, "between the trailing spaces");
}
}
#[test]
fn colour_change_keeps_kerning_and_ligatures_keep_colour() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 400.0, 200.0), lid, "ayay", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.font_style = Some("Bold".into()));
let line = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].clone();
let xs = |l: &Line| l.glyphs.iter().map(|g| (g.x, g.adv)).collect::<Vec<_>>();
let kerned = line(&d);
// The font kerns "ay" (otherwise this test proves nothing).
d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.kerning = Some(designcraft_doc::Kerning::None));
assert_ne!(xs(&line(&d)), xs(&kerned), "Source Serif 4 Bold kerns a–y");
d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.kerning = Some(designcraft_doc::Kerning::Metrics));
// Colour the first "a": positions are unchanged, only its style differs.
d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.fill = Some("Cyan".into()));
let coloured = line(&d);
assert_eq!(xs(&coloured), xs(&kerned), "a colour change must not change spacing");
let cs = compose_story(&d, sid, &ComposeOptions::default());
let fill = |l: &Line, i: usize| cs.styles[l.glyphs[i].style as usize].fill.clone();
assert_eq!(fill(&coloured, 0), "Cyan");
assert_ne!(fill(&coloured, 1), "Cyan");
// A ligature never swallows a differently coloured letter: the "i" of "fi" keeps its colour.
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 300.0, 400.0, 400.0), lid, "fifi", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().format_chars(1..2, |f| f.over.fill = Some("Cyan".into()));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
let at = |b: usize| l.glyphs.iter().find(|g| g.len > 0 && g.byte <= b && b < g.byte + g.len).map(|g| cs.styles[g.style as usize].fill.clone());
assert_eq!(at(1).as_deref(), Some("Cyan"));
assert_ne!(at(2).as_deref(), Some("Cyan"));
}
#[test]
fn cjk_aki_adds_space_without_scaling_outlines() {
let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
+18 -2
View File
@@ -431,6 +431,8 @@ pub enum NestedUntil {
ForcedLineBreak,
EmSpace,
EnSpace,
/// The paragraph's drop cap characters.
Dropcap,
/// Any of these characters.
Chars(String),
}
@@ -530,12 +532,16 @@ pub struct TabStop {
pub align_on: String,
}
/// The colour of a paragraph rule that follows the paragraph's text colour.
pub const TEXT_COLOR: &str = "Text Color";
/// A paragraph rule (Rule Above / Rule Below).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Rule {
pub on: bool,
pub weight: f64,
/// A swatch, or [`TEXT_COLOR`].
pub color: String,
pub tint: f32,
/// Width: column (true) or text (false).
@@ -547,7 +553,7 @@ pub struct Rule {
impl Default for Rule {
fn default() -> Self {
Rule { on: false, weight: 1.0, color: "[Black]".into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
}
}
@@ -727,8 +733,17 @@ attr_set! {
last_line_indent: f64 = 0.0,
space_before: f64 = 0.0,
space_after: f64 = 0.0,
/// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never
/// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the
/// character style `drop_cap_style` ("" or `[None]`: the text's own formatting).
drop_cap_lines: u32 = 0,
drop_cap_chars: u32 = 0,
drop_cap_style: String = String::new(),
/// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side
/// bearing is taken off), and the lines beside it start where its advance ends.
drop_cap_align_left: bool = true,
/// Scale for Descenders (kept and round-tripped; doesn't change the layout).
drop_cap_scale_descenders: bool = false,
/// Nested styles, in order from the paragraph start.
nested_styles: Vec<NestedStyle> = Vec::new(),
/// GREP styles: a character style for every match of a pattern (applied after nested).
@@ -773,7 +788,8 @@ attr_set! {
// Keeps
keep_with_next: u32 = 0,
keep_lines_together: bool = false,
keep_all_lines: bool = true,
/// All lines in paragraph (true) or at start/end of paragraph (false).
keep_all_lines: bool = false,
keep_first: u32 = 2,
keep_last: u32 = 2,
start_paragraph: StartParagraph = StartParagraph::Anywhere,
+146 -63
View File
@@ -193,87 +193,146 @@ impl Document {
Ok((id, sid))
}
/// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text,
/// that text is appended to the story (as a new paragraph) and its old story is removed.
/// Thread `from` → `to`: `to` joins `from`'s story right after `from` (between `from` and the
/// frame that followed it, if any). If `to` held its own text, that text is appended to the
/// story (as new paragraphs) and its old story is removed; frames that followed `to` come
/// along, frames before it keep an empty story of their own. An empty (unassigned) `from` or
/// `to` becomes a text frame. `from`'s story keeps its id.
pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?;
// Convert unassigned/graphic-less frames into text frames.
let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() {
Content::Text(t) => Some(t.story),
Content::Unassigned => None,
_ => return Err(DocError::Invalid("can only thread to text or empty frames".into())),
let ts = self.frame_story(to)?;
let fs = match self.frame_story(from)? {
Some(s) => s,
None => self.give_empty_story(from)?,
};
if ts == Some(fs) {
return Err(DocError::Invalid("frames are already in the same story".into()));
}
if let Some(ts) = ts {
let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?;
let other = Arc::unwrap_or_clone(other);
// Frames of the other story after `to` follow along; frames before it are left as empty new stories.
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let before: Vec<ItemId> = other.frames[..pos_to].to_vec();
let after: Vec<ItemId> = other.frames[pos_to..].to_vec();
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
if !other.text.is_empty() {
st.text.push('\n');
st.text.push_str(&other.text);
st.paras.extend(other.paras.iter().cloned());
// Char runs: '\n' takes the last run's format, then the other story's runs.
if let Some(last) = st.chars.last_mut() {
last.len += 1;
}
st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned());
st.tables.extend(other.tables.iter().map(|(k, v)| (*k, v.clone())));
for n in &other.notes {
let id = st.next_note_id();
st.notes.push(Arc::new(crate::Footnote { id, text: n.text.clone() }));
}
st.anchors.extend(other.anchors.iter().cloned());
st.xrefs.extend(other.xrefs.iter().cloned());
st.index_refs.extend(other.index_refs.iter().cloned());
st.objects.extend(other.objects.iter().cloned());
st.rev += 1;
}
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
}
}
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
ns.vertical = other.vertical;
ns.direction = other.direction;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
} else {
let Some(ts) = ts else {
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
st.frames.insert(at, to);
st.rev += 1;
let it = self.item_mut(to).ok_or(DocError::NoItem(to))?;
it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() });
}
if let Some(st) = self.story_mut(fs) {
return Ok(());
};
let mut other = Arc::unwrap_or_clone(self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?);
// Frames of the other story from `to` on follow along; frames before it are left as an empty new story.
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let after = other.frames.split_off(pos_to);
let before = std::mem::take(&mut other.frames);
let (vertical, direction) = (other.vertical, other.direction);
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
// The story keeps its own direction, even when it was empty and takes the other's text.
let own = (st.vertical, st.direction);
st.append_story(other);
(st.vertical, st.direction) = own;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
}
st.rev += 1;
}
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
ns.vertical = vertical;
ns.direction = direction;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
Ok(())
}
/// [`Document::thread`], except that an empty `from` alone in its story, threaded in front of
/// the first frame of `to`'s story, joins that story: the story with the text keeps its id
/// (and whatever refers to it).
pub fn thread_keeping_story(&mut self, from: ItemId, to: ItemId) -> Result<()> {
let fs = self.frame_story(from)?;
let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from]));
let ts = self.frame_story(to)?;
let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to));
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) };
if let Some(fs) = fs {
self.stories.remove(&fs);
}
let st = self.story_mut(ts).ok_or(DocError::NoStory(ts))?;
st.frames.insert(0, from);
st.rev += 1;
let it = self.item_mut(from).ok_or(DocError::NoItem(from))?;
match it.text_frame_mut() {
Some(t) => t.story = ts,
None => it.content = Content::Text(TextFrame { story: ts, options: TextFrameOptions::default() }),
}
Ok(())
}
/// Can a user thread `from` → `to`? Both are text frames or empty frames (not lines), on
/// document spreads or on the same parent spread, not in one story already, and no frame
/// threads into `to` yet. [`Document::thread`] itself is less strict (import and autoflow).
pub fn check_thread(&self, from: ItemId, to: ItemId) -> Result<()> {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
for id in [from, to] {
let it = self.item(id).ok_or(DocError::NoItem(id))?;
if !matches!(it.content, Content::Text(_) | Content::Unassigned) || it.shape == Shape::GraphicLine {
return Err(DocError::Invalid(format!("{id} is not a text frame or an empty frame")));
}
}
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) };
let same_place = match (fl.spread, tl.spread) {
(SpreadRef::Doc(_), SpreadRef::Doc(_)) => true,
(a, b) => a == b,
};
if !same_place {
return Err(DocError::Invalid("frames on a parent spread thread only to frames on the same parent".into()));
}
let story = |id: ItemId| self.item(id).and_then(|i| i.text_frame()).map(|t| t.story);
if story(from).is_some() && story(from) == story(to) {
return Err(DocError::Invalid("the frames are already in the same thread".into()));
}
if self.prev_frame(to).is_some() {
return Err(DocError::Invalid(format!("text already flows into {to} from another frame: break that thread first")));
}
Ok(())
}
/// The story of a frame that can take part in a thread: `Some` for a text frame, `None` for
/// an empty (unassigned) frame.
fn frame_story(&self, id: ItemId) -> Result<Option<StoryId>> {
match &self.item(id).ok_or(DocError::NoItem(id))?.content {
Content::Text(t) => Ok(Some(t.story)),
Content::Unassigned => Ok(None),
_ => Err(DocError::Invalid("can only thread text frames or empty frames".into())),
}
}
/// Make an empty frame a text frame with a new empty story.
fn give_empty_story(&mut self, id: ItemId) -> Result<StoryId> {
let sid = StoryId(self.alloc());
let mut st = Story::new(sid);
st.frames.push(id);
let it = self.item_mut(id).ok_or(DocError::NoItem(id))?;
it.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
self.stories.insert(sid, Arc::new(st));
Ok(sid)
}
/// Break the thread after `frame`: the following frames become a new empty story (the text
/// stays with the first part and may become overset).
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
@@ -302,6 +361,30 @@ impl Document {
Ok(())
}
/// Take `frame` out of its thread: the frames before and after it join up and keep the text,
/// `frame` stays on the page with a new empty story.
pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (vertical, direction) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
if st.frames.len() < 2 {
return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame")));
}
st.frames.retain(|f| *f != frame);
st.rev += 1;
(st.vertical, st.direction)
};
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = vec![frame];
ns.vertical = vertical;
ns.direction = direction;
self.stories.insert(nid, Arc::new(ns));
let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?;
t.story = nid;
Ok(())
}
/// Next / previous frame in a thread.
pub fn next_frame(&self, frame: ItemId) -> Option<ItemId> {
let st = self.story(self.item(frame)?.text_frame()?.story)?;
+2 -2
View File
@@ -321,7 +321,7 @@ pub struct TextFrameOptions {
pub first_baseline_min: f64,
pub ignore_wrap: bool,
pub auto_size: AutoSize,
/// Reference point (0..9) that stays fixed when auto-sizing.
/// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing.
pub auto_size_ref: u8,
pub column_rule: bool,
pub column_rule_weight: f64,
@@ -371,7 +371,7 @@ impl Default for TextFrameOptions {
first_baseline_min: 0.0,
ignore_wrap: false,
auto_size: AutoSize::Off,
auto_size_ref: 1,
auto_size_ref: 4,
column_rule: false,
column_rule_weight: 1.0,
column_rule_color: designcraft_color::swatch::BLACK.into(),
+1 -7
View File
@@ -141,7 +141,7 @@ impl Default for DocumentGrid {
}
/// A named numbered list.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NumberedList {
pub name: String,
@@ -149,12 +149,6 @@ pub struct NumberedList {
pub continue_across_stories: bool,
}
impl Default for NumberedList {
fn default() -> Self {
NumberedList { name: String::new(), continue_across_stories: true }
}
}
/// A conditional-text condition: its indicator colour (screen only) and whether text with it shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+1 -1
View File
@@ -178,7 +178,7 @@ impl Story {
}
pub(crate) fn next_note_id(&self) -> u64 {
self.notes.iter().map(|n| n.id).max().unwrap_or(0) + 1
self.notes.iter().map(|n| n.id).max().unwrap_or(0).saturating_add(1)
}
/// Insert empty footnotes for `n` new reference characters before which `k` references sit.
+73
View File
@@ -126,6 +126,61 @@ impl Story {
self.rev += 1;
end
}
/// Append the whole of `other` as new paragraphs after this story's text (threading two
/// stories merges them this way). Its formatting, notes, endnotes, editorial notes, markers,
/// anchored objects and tables come along; footnotes, endnotes, editorial notes and tables get
/// ids that are free in this story. An empty story simply takes `other`'s content. Frames stay.
pub fn append_story(&mut self, mut other: Story) {
if other.text.is_empty() {
return;
}
if self.text.is_empty() {
let (id, frames, rev, link) = (self.id, std::mem::take(&mut self.frames), self.rev, self.link.take());
*self = Story { id, frames, rev: rev.saturating_add(1), link, ..other };
return;
}
if !other.tables.is_empty() {
let mut next = self.tables.keys().max().copied().unwrap_or(0).saturating_add(1);
let mut ids = std::collections::HashMap::new();
for (old, t) in std::mem::take(&mut other.tables) {
let new = next;
next = next.saturating_add(1);
let mut t = Arc::unwrap_or_clone(t);
t.id = new;
ids.insert(old, new);
self.tables.insert(new, Arc::new(t));
}
for p in &mut other.paras {
p.table = p.table.and_then(|id| ids.get(&id).copied());
}
}
self.text.push('\n');
self.text.push_str(&other.text);
// The paragraph end takes the last run's format.
if let Some(last) = self.chars.last_mut() {
last.len += 1;
}
self.chars.extend(other.chars.into_iter().filter(|r| r.len > 0));
self.paras.extend(other.paras);
for n in other.notes {
let id = self.next_note_id();
self.notes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() }));
}
for n in other.endnotes {
let id = self.endnotes.iter().map(|x| x.id).max().unwrap_or(0).saturating_add(1);
self.endnotes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() }));
}
for n in other.editorial {
let id = self.editorial.iter().map(|x| x.id).max().unwrap_or(0).saturating_add(1);
self.editorial.push(Arc::new(crate::endnotes::EditorialNote { id, ..(*n).clone() }));
}
self.anchors.extend(other.anchors);
self.xrefs.extend(other.xrefs);
self.index_refs.extend(other.index_refs);
self.objects.extend(other.objects);
self.rev = self.rev.saturating_add(1);
}
}
#[cfg(test)]
@@ -159,4 +214,22 @@ mod tests {
assert_eq!(part.notes.len(), 1);
assert!(a.extract(2..8).notes.is_empty());
}
#[test]
fn append_story_with_maximal_ids_does_not_overflow() {
let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default());
a.tables.insert(u64::MAX, Arc::new(crate::table::Table::new(u64::MAX, 1, 1, 0, 0, 100.0)));
let note = |id| Arc::new(crate::notes::Footnote { id, text: Story::with_text(StoryId(0), "n", ParaFormat::default()) });
a.endnotes.push(note(u64::MAX));
a.notes.push(note(u64::MAX));
a.rev = u64::MAX;
let mut b = Story::with_text(StoryId(2), "Beta", ParaFormat::default());
b.tables.insert(1, Arc::new(crate::table::Table::new(1, 1, 1, 0, 0, 100.0)));
b.endnotes.push(note(1));
b.notes.push(note(1));
a.append_story(b);
assert_eq!(a.text, "Alpha\nBeta");
assert_eq!(a.endnotes.len(), 2);
assert_eq!(a.notes.len(), 2);
}
}
+2 -2
View File
@@ -280,8 +280,8 @@ impl Styles {
self.object.iter().find(|s| s.name == name)
}
/// The paragraph style chain from root to `name` (cycle-safe).
fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
/// The paragraph style chain from root to `name` (cycle-safe): resolution starts at its first.
pub fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
let mut chain = Vec::new();
let mut cur = self.para(name);
while let Some(s) = cur {
+92
View File
@@ -42,6 +42,98 @@ fn thread_to_empty_frame_converts_it() {
assert!(d.thread(a, a).is_err());
}
#[test]
fn threading_merges_tables_notes_and_endnotes() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap();
for (sid, note) in [(sa, "Note A"), (sb, "Note B")] {
let st = d.story_mut(sid).unwrap();
st.insert_note(3, note, ParaFormat::default());
// Both stories hold a table with id 1.
let n = st.len();
st.insert_table(n, crate::table::Table::new(1, 2, 2, 0, 0, 80.0));
}
let st = d.story_mut(sb).unwrap();
st.insert_endnote(2, "End B", ParaFormat::default());
st.insert(1, &crate::NOTE_MARK.to_string());
d.thread(a, b).unwrap();
d.check().unwrap();
let st = d.story(sa).unwrap();
assert!(d.story(sb).is_none());
assert_eq!(st.tables.len(), 2);
assert_eq!(st.notes.len(), 2);
assert_eq!(st.notes[1].text.text, "Note B");
assert_eq!(st.endnotes.len(), 1);
assert_eq!(st.endnotes[0].text.text, "End B");
assert_eq!(st.editorial.len(), 1);
}
#[test]
fn empty_frame_threaded_before_a_story_joins_it_at_the_front() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread_keeping_story(a, b).unwrap();
d.check().unwrap();
assert!(d.story(sa).is_none());
let st = d.story(sb).unwrap();
assert_eq!(st.frames, vec![a, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn empty_story_takes_the_threaded_text_without_a_blank_paragraph() {
let mut d = doc();
let lid = d.default_layer();
let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (a2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 110.0, 100.0, 200.0), lid, "", ParaFormat::default()).unwrap();
d.thread(a, a2).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread(a2, b).unwrap();
d.check().unwrap();
let st = d.story(d.item(a).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.frames, vec![a, a2, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn threading_from_an_empty_frame_makes_it_a_text_frame() {
let mut d = doc();
let lid = d.default_layer();
let id = ItemId(d.alloc());
let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0)));
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread(id, b).unwrap();
d.check().unwrap();
let st = d.story(d.item(id).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.frames, vec![id, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn removing_a_frame_from_its_thread_keeps_the_text_in_the_others() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "words", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 10.0, 300.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.thread(a, b).unwrap();
d.thread(b, c).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
assert_eq!(d.story(sa).unwrap().frames, vec![a, c]);
assert_eq!(d.story(sa).unwrap().text, "words");
let own = d.item(b).unwrap().text_frame().unwrap().story;
assert_ne!(own, sa);
assert!(d.story(own).unwrap().text.is_empty());
// A frame alone in its story has no thread to leave.
assert!(d.remove_from_thread(b).is_err());
}
#[test]
fn frames_keep_their_story_direction_through_threading() {
let mut d = doc();
+2
View File
@@ -50,6 +50,7 @@ mod strokes;
mod style;
pub mod table;
pub mod text;
mod thread;
mod toc;
mod transitions;
mod variables;
@@ -184,6 +185,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(package::specs());
v.extend(print::specs());
v.extend(text::specs());
v.extend(thread::specs());
v.extend(style::specs());
v.extend(table::specs());
v.extend(layout::specs());
+21 -5
View File
@@ -164,8 +164,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Color",
[],
None,
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection",
super::has_selection,
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)",
|s| super::has_text(s).or_else(|_| super::has_selection(s)),
apply_color
),
cmd!(
@@ -858,8 +858,11 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?;
let stroke = str_param(p, "target") == Some("stroke");
let ids = super::targets(s, p)?;
s.edit(|d, _| {
let text = p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some();
let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) };
// A caret: the colour is for the next typing (once the swatch exists).
let caret = matches!(texts.as_slice(), [t] if t.range.is_empty());
let r = s.edit(|d, _| {
// Reuse a swatch holding exactly this colour, else add an unnamed one.
let existing = d
.swatches
@@ -891,8 +894,21 @@ fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
if !caret {
let a = if stroke {
CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() }
} else {
CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() }
};
super::text::apply_char_attrs(d, &texts, &a, &[]);
}
Ok(json!({"swatch": name}))
})
})?;
if caret && let Some(name) = r.get("swatch").cloned() {
let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) };
super::text::format_chars(s, &attrs)?;
}
Ok(r)
}
fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
+360 -43
View File
@@ -22,15 +22,18 @@ pub fn specs() -> Vec<CommandSpec> {
release
),
cmd!(noundo "text.extendTo", "Extend Text Selection", [], None, "{frame, point}", has_doc, |s, p| place(s, p, true)),
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point}", has_doc, |s, p| {
place(s, p, false)?;
let st = s.doc_mut()?;
let Some(t) = st.selection.text else { return ok() };
let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default();
let (a, b) = word_bounds(text, t.focus);
st.selection.text = Some(TextSel { anchor: a, focus: b, ..t });
ok()
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point} — the word (or run of spaces) under the point", has_doc, |s, p| {
select_at(s, p, "word")
}),
cmd!(
noundo "text.selectAt",
"Select Text Unit",
[],
None,
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, paragraph or the whole story",
has_doc,
|s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word"))
),
cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0));
let st = s.doc_mut()?;
@@ -135,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, …}}",
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?, composer?, tabs?, …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
@@ -152,8 +155,24 @@ pub fn specs() -> Vec<CommandSpec> {
s,
&json!({"align": "leftJustified"})
)),
cmd!("type.bold", "Bold", [], Some("Cmd+Shift+B"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Bold"}))),
cmd!("type.italic", "Italic", [], Some("Cmd+Shift+I"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Italic"}))),
cmd!(
"type.bold",
"Bold",
[],
Some("Cmd+Shift+B"),
"{on?: bool} — the family's bold style, keeping italic (style linking); toggles when `on` is left out",
has_text_or_frames,
|s, p| link_style(s, p, true)
),
cmd!(
"type.italic",
"Italic",
[],
Some("Cmd+Shift+I"),
"{on?: bool} — the family's italic style, keeping the weight (style linking); toggles when `on` is left out",
has_text_or_frames,
|s, p| link_style(s, p, false)
),
cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))),
cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars(
s,
@@ -262,6 +281,17 @@ pub(crate) fn story_of(s: &Session, p: &Value) -> Option<StoryId> {
/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell).
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option<CellAddr>)> {
hit_byte_with(s, frame, pt, compose::hit)
}
/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`]
/// (the character under the point).
fn hit_byte_with(
s: &Session,
frame: ItemId,
pt: Point,
hit: fn(&compose::ComposedStory, usize, Point) -> Option<usize>,
) -> Option<(StoryId, usize, Option<CellAddr>)> {
let st = s.active()?;
let loc = st.doc.find(frame)?;
let it = st.doc.item_at(&loc)?;
@@ -270,16 +300,76 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op
let inner = xf.inverse() * pt;
let cs = s.cache.get(&st.doc, sid, None);
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) {
return Some((sid, b, Some(CellAddr { table, row, col })));
}
if let Some((id, b)) = compose::hit_note(&cs, fi, inner) {
if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) {
return Some((sid, b, Some(CellAddr::footnote(id))));
}
let b = compose::hit(&cs, fi, inner).unwrap_or(0);
let b = hit(&cs, fi, inner).unwrap_or(0);
Some((sid, b, None))
}
/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks).
fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
const ID: &str = "text.selectAt";
let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?;
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?;
let st = s.doc()?;
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let text = story.text.as_str();
let b = floor_char_boundary(text, b);
let (a, e) = match unit {
"word" => word_or_spaces_at(text, b),
"line" => {
let cs = s.cache.get(&st.doc, sid, None);
line_at(&text_composition(cs, cell), b).unwrap_or((b, b))
}
"paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, r.end)),
"story" => (0, text.len()),
other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))),
};
s.text_drag = false;
let st = s.doc_mut()?;
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell });
st.revision += 1;
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
}
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character. At
/// the end of a paragraph, what ends it.
fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) {
let at = |i: usize| s.get(i..).and_then(|r| r.chars().next()).filter(|c| *c != '\n');
let i = if at(i).is_none() && i > 0 { prev_char(s, i) } else { i };
let Some(c) = at(i) else { return (i, i) };
let space = |c: char| c.is_whitespace() && c != '\n';
if is_word(c) {
word_bounds(s, i)
} else if space(c) {
let a = s[..i].char_indices().rev().take_while(|(_, c)| space(*c)).last().map_or(i, |(k, _)| k);
let e = s[i..].char_indices().find(|(_, c)| !space(*c)).map_or(s.len(), |(k, _)| i + k);
(a, e)
} else {
(i, i + c.len_utf8())
}
}
/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's.
fn text_composition(cs: std::sync::Arc<compose::ComposedStory>, cell: Option<CellAddr>) -> std::sync::Arc<compose::ComposedStory> {
match cell {
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
None => cs,
}
}
/// The composed line holding the character at byte `pos`.
fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
let lines = || cs.frames.iter().flat_map(|f| f.lines.iter());
lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos))
}
fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?;
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
@@ -365,6 +455,8 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text };
let tracking = s.doc()?.doc.settings.track_changes;
let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone());
let typing = typing_format(s).cloned();
s.typing_format = None;
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
@@ -412,6 +504,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
// Track Changes: the replaced text is marked deleted, the typing inserted after it.
let at = super::changes::mark_deleted(st, r.clone());
let mut fmt = st.char_format_at(at).clone();
if let Some(f) = &typing {
apply_typing_format(&mut fmt, f);
}
fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted);
st.insert_with(at, &text, fmt);
let pos = at + text.len() - trail;
@@ -419,6 +514,11 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"pos": pos}));
}
st.replace(r.clone(), &text);
if let Some(f) = &typing
&& !text.is_empty()
{
st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f));
}
let pos = r.start + text.len() - trail;
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
Ok(json!({"pos": pos}))
@@ -628,13 +728,8 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?;
let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let text = story.text.clone();
let cs = s.cache.get(&st.doc, t.story, None);
// In a cell, lines come from the cell's own composition.
let cs = match t.cell {
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
None => cs,
};
let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell);
let pos = t.focus.min(text.len());
// Vertical lines run top to bottom and follow each other right to left: Down and Up move along
// the line (Right and Left of horizontal text), Left and Right to the next and previous line.
@@ -736,6 +831,23 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
v
}
/// Character formatting chosen at a bare caret in text: applied to what is typed there next.
#[derive(Clone, Debug)]
pub(crate) struct TypingFormat {
doc: u64,
at: TextSel,
attrs: CharAttrs,
/// Overrides removed (`null` in `type.char`).
cleared: Vec<String>,
}
/// The typing format for the active document's caret, if it was chosen at this caret.
fn typing_format(s: &Session) -> Option<&TypingFormat> {
let st = s.active()?;
let t = st.selection.text?;
s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range())
}
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
let mut a = CharAttrs::default();
if let Some(o) = attrs.as_object() {
@@ -745,32 +857,50 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
}
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
let targets = format_targets(s);
// A caret in text: the format is for the next typing.
if let [t] = targets.as_slice()
&& t.range.is_empty()
&& let Some(at) = s.doc()?.selection.text
&& s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty())
{
let doc = s.doc()?.uid;
let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] });
f.attrs.merge(&a);
f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k));
f.cleared.extend(cleared);
s.typing_format = Some(f);
return ok();
}
s.edit(|d, _| {
for t in &targets {
let r = &t.range;
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
if r.is_empty() {
// Caret: change the typing format (the empty run at the caret).
let pos = r.start;
if st.is_empty() {
st.chars[0].format.over.merge(&a);
st.rev += 1;
} else {
let _ = pos;
}
continue;
}
st.format_chars(r.clone(), |f| {
f.over.merge(&a);
for k in &cleared {
let _ = f.over.set_json(k, &Value::Null);
}
});
}
apply_char_attrs(d, &targets, &a, &cleared);
ok()
})
}
/// Character attributes `a` (and the overrides `cleared` removed) over each target's text.
pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) {
for t in targets {
let r = &t.range;
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
if r.is_empty() {
// An empty story: its one (empty) run is the format typing takes.
if st.is_empty()
&& let Some(run) = st.chars.first_mut()
{
run.format.over.merge(a);
st.rev += 1;
}
continue;
}
st.format_chars(r.clone(), |f| {
f.over.merge(a);
for k in cleared {
let _ = f.over.set_json(k, &Value::Null);
}
});
}
}
pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
let mut a = ParaAttrs::default();
if let Some(o) = attrs.as_object() {
@@ -874,12 +1004,72 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
let pi = story.para_at(r.start);
let pf = &story.paras[pi];
let (pp, base) = st.doc.styles.resolve_para(pf);
let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) };
let cp = st.doc.styles.resolve_char(&base, cf);
let mut cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }.clone();
if let Some(f) = typing_format(s) {
apply_typing_format(&mut cf, f);
}
let cp = st.doc.styles.resolve_char(&base, &cf);
Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count()}))
}
fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) {
cf.over.merge(&f.attrs);
for k in &f.cleared {
let _ = cf.over.set_json(k, &Value::Null);
}
}
/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or
/// off as asked, else the opposite of the first selected character.
fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> {
let id = if bold { "type.bold" } else { "type.italic" };
let cur = selection_attrs(s, &json!({}))?;
let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string();
let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string();
// The document's own fonts count too.
let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope);
let (weight, italic) = db.traits_of(&family, &style);
let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic });
let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) };
let targets = format_targets(s);
// A caret: the linked style of the format there, for the next typing.
if let [t] = targets.as_slice()
&& t.range.is_empty()
{
let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?;
format_chars(s, &json!({"fontStyle": linked}))?;
return Ok(json!({"on": on, "fontStyle": linked}));
}
let mut changed = 0;
let r = 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 };
for (pi, pr) in st.para_ranges().into_iter().enumerate() {
let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end));
if a >= b {
continue;
}
let Some(pf) = st.paras.get(pi) else { continue };
let (_, pc) = styles.resolve_para(pf);
st.format_chars(a..b, |f| {
let cp = styles.resolve_char(&pc, f);
if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic)
&& linked != cp.font_style
{
f.over.font_style = Some(linked);
changed += 1;
}
});
}
}
ok()
});
r?;
Ok(json!({"on": on, "changed": changed}))
}
/// Our own filler text (not Adobe's).
pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \
et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \
@@ -1148,6 +1338,133 @@ mod nested_style_tests {
}
}
#[cfg(test)]
mod multi_click_tests {
use serde_json::{Value, json};
use crate::Session;
#[test]
fn clicks_select_words_spaces_lines_paragraphs_and_the_story() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let text = "Hello brave world.\nSecond paragraph here.";
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
// Spread point over the right half of the glyph at byte `b`.
let at = |s: &Session, b: usize| {
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap();
json!([g.x + g.adv * 0.75, l.baseline - 3.0])
};
let mut select = |unit: &str, b: usize| {
let p = at(&s, b);
let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap();
(r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize)
};
assert_eq!(select("word", 1), (0, 5), "inside a word");
assert_eq!(select("word", 4), (0, 5), "its last letter");
assert_eq!(select("word", 5), (5, 7), "the run of spaces after it");
assert_eq!(select("word", 18), (18, 19), "punctuation alone");
assert_eq!(select("line", 9), (0, 19));
assert_eq!(select("paragraph", 9), (0, 19));
assert_eq!(select("paragraph", 22), (20, text.len()));
assert_eq!(select("story", 9), (0, text.len()));
// A second click inside the selected word selects again rather than starting a text drag.
s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap();
let p = at(&s, 9);
s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (0, 19));
s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it");
// The older word command agrees.
let p = at(&s, 8);
s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (7, 12));
assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err());
}
}
#[cfg(test)]
mod style_link_tests {
use serde_json::json;
use crate::Session;
#[test]
fn bold_and_italic_follow_the_family_and_toggle() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap();
let sid = r["story"].as_u64().unwrap();
let style_at = |s: &mut Session, b: usize| {
s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap();
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string()
};
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
s.execute("type.italic", &json!({})).unwrap();
assert_eq!(style_at(&mut s, 0), "Italic");
// Bold over italic and plain text: each run takes its own linked style.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
assert_eq!(style_at(&mut s, 0), "Bold Italic");
assert_eq!(style_at(&mut s, 7), "Bold");
// Again: off, back to each run's regular weight.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
assert_eq!(style_at(&mut s, 0), "Italic");
assert_eq!(style_at(&mut s, 7), "Regular");
// At a caret the style is for what is typed next.
let end = "plain words here".len();
s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold");
s.execute("text.insert", &json!({"text": " now"})).unwrap();
assert_eq!(style_at(&mut s, end + 1), "Bold");
assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged");
// Moving the caret forgets it.
s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
s.execute("text.insert", &json!({"text": "x"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0);
}
}
#[cfg(test)]
mod text_color_tests {
use serde_json::json;
use crate::Session;
#[test]
fn unnamed_colours_go_to_text_when_asked() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
s.execute("selection.set", &json!({"ids": [fid]})).unwrap();
let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();
let before = fill_of_frame(&s);
let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap();
let red = r["swatch"].as_str().unwrap().to_string();
assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill");
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str()));
// A text selection takes it without asking.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap();
let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap();
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str());
assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour");
}
}
#[cfg(test)]
mod drag_text_tests {
use serde_json::json;
+335
View File
@@ -0,0 +1,335 @@
//! Threading text frames: thread a frame after another (their stories merge), break a thread,
//! take a frame out of its thread. The Selection tool's loaded text cursor runs these.
use designcraft_doc::{Document, ItemId, ParaFormat, Selection, StoryId};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, id_param, rect_param, spread_param, str_param};
use crate::{Result, Session};
/// Pages Shift-click autoflow may add before it gives up.
const AUTOFLOW_MAX_PAGES: usize = 2000;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"text.thread",
"Thread Text Frames",
[],
None,
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
has_doc,
thread
),
cmd!(
"text.unthread",
"Break Thread",
[],
None,
"{frame?, side?: after|before} — breaks the thread after (or before) `frame` (default: the selected text frame). The frames from the break on stay threaded to each other but become empty; the text stays in the story, overset if the earlier frames can't hold it",
has_doc,
unthread
),
cmd!(
"text.removeFromThread",
"Remove Frame from Thread",
[],
None,
"{frame?} — takes `frame` (default: the selected text frame) out of its thread: the frames before and after it join up and keep the text, `frame` stays empty",
has_doc,
remove_from_thread
),
]
}
/// `frame` param, or the first selected text frame.
fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
let st = s.doc()?;
let id = id_param(p, "frame")
.or_else(|| st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.is_text_frame())))
.ok_or_else(|| bad(cmd, "no text frame given or selected"))?;
if !st.doc.item(id).is_some_and(|it| it.is_text_frame()) {
return Err(bad(cmd, format!("{id} is not a text frame")));
}
Ok(id)
}
/// A rect param made safe for a frame: finite, ordered, at least half a point each way.
fn frame_rect(p: &Value) -> Result<Option<Rect>> {
let Some(r) = rect_param(p, "rect") else { return Ok(None) };
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
return Err(bad("text.thread", "rect must be finite"));
}
let (x0, x1) = (r.x0.min(r.x1), r.x0.max(r.x1));
let (y0, y1) = (r.y0.min(r.y1), r.y0.max(r.y1));
Ok(Some(Rect::new(x0, y0, x1.max(x0 + 0.5), y1.max(y0 + 0.5))))
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let (from, to, rect) = (id_param(p, "from"), id_param(p, "to"), frame_rect(p)?);
let sr = spread_param(p, "spread");
let autoflow = bool_or(p, "autoflow", false);
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
let mut new_frame = || -> Result<ItemId> {
let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?;
let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() };
Ok(d.add_text_frame(sr, r, layer, "", para)?.0)
};
let (from, to, joined) = match (from, to) {
(Some(f), Some(t)) if rect.is_none() => (f, t, t),
(Some(f), None) => {
let t = new_frame()?;
(f, t, t)
}
(None, Some(t)) => {
let f = new_frame()?;
(f, t, f)
}
_ => return Err(bad("text.thread", "give two of from, to and rect")),
};
d.check_thread(from, to)?;
d.thread_keeping_story(from, to)?;
let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?;
let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES)? } else { 0 };
*sel = Selection::items(vec![joined]);
Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages}))
})
}
fn overset(d: &Document, sid: StoryId) -> bool {
designcraft_compose::compose_story(d, sid, &Default::default()).overset_at.is_some()
}
fn unthread(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "text.unthread";
let frame = frame_param(s, p, CMD)?;
let d0 = &s.doc()?.doc;
let at = match str_param(p, "side").unwrap_or("after") {
"after" => d0.next_frame(frame).map(|_| frame).ok_or_else(|| bad(CMD, format!("no frame follows {frame} in its thread")))?,
"before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?,
other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))),
};
s.edit(|d, _| {
d.unthread_after(at)?;
let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?;
Ok(json!({"story": sid.0, "overset": overset(d, sid)}))
})
}
fn remove_from_thread(s: &mut Session, p: &Value) -> Result<Value> {
let frame = frame_param(s, p, "text.removeFromThread")?;
s.edit(|d, _| {
d.remove_from_thread(frame)?;
Ok(json!({}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s
}
fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (u64, u64) {
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false})).unwrap();
(r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap())
}
fn story(s: &mut Session, frame: u64) -> serde_json::Value {
s.execute("story.get", &json!({"frame": frame})).unwrap()
}
fn frames(s: &mut Session, frame: u64) -> Vec<u64> {
story(s, frame)["frames"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
const LONG: &str = "Threaded text keeps flowing from one frame into the next one. ";
#[test]
fn thread_into_an_empty_frame_and_undo() {
let mut s = session();
let (a, sid) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(10));
let b = s.execute("frame.create", &json!({"rect": [72, 300, 200, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
assert!(!story(&mut s, a)["overset"].is_null(), "the first frame is overset");
let r = s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
assert_eq!(r["story"].as_u64(), Some(sid));
assert_eq!(frames(&mut s, a), [a, b]);
let d = &s.doc().unwrap().doc;
assert!(d.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(b)]);
// The text now reaches the second frame.
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default());
assert!(cs.frames.iter().any(|f| f.frame.0 == b && !f.range.is_empty()), "text flows into the new frame");
s.doc().unwrap().doc.check().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a]);
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
}
#[test]
fn thread_into_a_text_frame_merges_its_story() {
let mut s = session();
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 200.0], "First");
let (b, sb) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "Second");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
let st = story(&mut s, b);
assert_eq!(st["id"].as_u64(), Some(sa));
assert_eq!(st["text"], "First\nSecond");
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sb)).is_none());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn thread_into_the_middle_of_a_thread() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let (c, _) = text_frame(&mut s, [72.0, 500.0, 200.0, 600.0], "");
s.execute("text.thread", &json!({"from": a, "to": c})).unwrap();
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
assert_eq!(frames(&mut s, a), [a, b, c]);
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn bad_threads_are_errors_and_change_nothing() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], "a");
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "b");
let (x, _) = text_frame(&mut s, [300.0, 72.0, 400.0, 120.0], "x");
let (y, _) = text_frame(&mut s, [300.0, 300.0, 400.0, 400.0], "y");
let g = s.execute("line.create", &json!({"a": [300, 500], "b": [400, 600]})).unwrap()["id"].as_u64().unwrap();
let group = {
let p = s.execute("frame.create", &json!({"rect": [500, 500, 550, 550], "content": "unassigned"})).unwrap()["id"].clone();
let q = s.execute("frame.create", &json!({"rect": [560, 500, 600, 550], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [p, q]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.doc().unwrap().selection.items[0].0
};
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": x, "to": y})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let doc = s.doc().unwrap().doc.clone();
for p in [
json!({"from": a, "to": a}),
json!({"from": a, "to": g}),
json!({"from": g, "to": a}),
json!({"from": a, "to": group}),
// Earlier in the same thread.
json!({"from": b, "to": a}),
// Text already flows into y from x.
json!({"from": a, "to": y}),
json!({"from": a, "to": 999_999}),
json!({"from": a}),
json!({"from": a, "to": b, "rect": [0, 0, 10, 10]}),
json!({"rect": [0, 0, 10, 10]}),
json!({"from": a, "rect": [0, 0, 10, 10], "spread": 99}),
json!({"from": "a", "to": [1]}),
json!([1, 2]),
] {
assert!(s.execute("text.thread", &p).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(*s.doc().unwrap().doc, *doc);
}
#[test]
fn thread_a_new_frame_after_or_in_front() {
let mut s = session();
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 200, 400]})).unwrap();
let n = r["to"].as_u64().unwrap();
assert_eq!(frames(&mut s, a), [a, n]);
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(n)]);
let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(n)).unwrap().bounds();
assert_eq!(b, Rect::new(72.0, 300.0, 200.0, 400.0));
// In front of the first frame: the story keeps its id and text.
let r = s.execute("text.thread", &json!({"rect": [300, 72, 400, 120], "to": a})).unwrap();
let m = r["from"].as_u64().unwrap();
assert_eq!(r["story"].as_u64(), Some(sa));
assert_eq!(frames(&mut s, a), [m, a, n]);
assert_eq!(story(&mut s, a)["text"], LONG.repeat(8));
s.doc().unwrap().doc.check().unwrap();
// One undo step each.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a, n]);
}
use designcraft_geom::Rect;
#[test]
fn thread_with_autoflow_adds_pages_until_the_text_fits() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 300.0, 200.0], &LONG.repeat(300));
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false);
assert!(r["pagesAdded"].as_u64().unwrap() >= 1, "{r}");
assert!(frames(&mut s, a).len() >= 3);
s.doc().unwrap().doc.check().unwrap();
// One undo removes the frames and the pages.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a]);
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
}
#[test]
fn unthread_after_and_before() {
let mut s = session();
let text = LONG.repeat(12);
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
// Break after the first frame: b and c stay threaded to each other, empty.
let r = s.execute("text.unthread", &json!({"frame": a})).unwrap();
assert_eq!(r["story"].as_u64(), Some(sa));
assert_eq!(r["overset"], true);
assert_eq!(frames(&mut s, a), [a]);
assert_eq!(story(&mut s, a)["text"], text);
assert_eq!(frames(&mut s, b), [b, c]);
assert_eq!(story(&mut s, b)["text"], "");
s.doc().unwrap().doc.check().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a, b, c]);
// Break before the last frame, with the frame selected.
s.execute("selection.set", &json!({"ids": [c]})).unwrap();
s.execute("text.unthread", &json!({"side": "before"})).unwrap();
assert_eq!(frames(&mut s, a), [a, b]);
assert_eq!(frames(&mut s, c), [c]);
// Nothing to break.
assert!(s.execute("text.unthread", &json!({"frame": b})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "before"})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "sideways"})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": 999_999})).is_err());
}
#[test]
fn deleting_or_removing_a_threaded_frame_keeps_the_text_flowing() {
let mut s = session();
let text = LONG.repeat(12);
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
s.execute("text.removeFromThread", &json!({"frame": b})).unwrap();
assert_eq!(frames(&mut s, a), [a, c]);
assert_eq!(story(&mut s, a)["text"], text);
assert_eq!(story(&mut s, b)["text"], "");
assert!(s.execute("text.removeFromThread", &json!({"frame": b})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.clear", &json!({"ids": [b]})).unwrap();
assert_eq!(frames(&mut s, a), [a, c]);
assert_eq!(story(&mut s, a)["text"], text);
s.doc().unwrap().doc.check().unwrap();
}
}
+3
View File
@@ -267,6 +267,8 @@ pub struct Session {
pub(crate) transforms: (Vec<(String, Value)>, Vec<designcraft_doc::ItemId>, bool),
/// Drag and drop text editing: the press landed in the selected text.
pub(crate) text_drag: bool,
/// Character formatting chosen at a bare caret, for the text typed there next.
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
/// The open Object Library (File › New / Open Library).
pub library: Option<cmd::library::Library>,
/// The open book (File › Open Book).
@@ -298,6 +300,7 @@ impl Session {
loaded: None,
transforms: Default::default(),
text_drag: false,
typing_format: None,
library: None,
conveyor: Vec::new(),
book: None,
+100
View File
@@ -537,3 +537,103 @@ fn commands_take_non_object_params_without_panicking() {
assert!(r.is_ok(), "{p}: {r:?}");
}
}
/// Load the text from a frame's out port with the Selection tool and click on the pasteboard:
/// a new frame is threaded there and the overset text flows into it.
#[test]
fn threading_with_the_loaded_text_cursor_on_the_sample() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
let (sid, first) = d.stories.values().find(|st| st.frames.len() >= 2).map(|st| (st.id, st.frames[0])).expect("a threaded story");
// Break the thread so the first frame is overset, and select it.
s.execute("text.unthread", &json!({"frame": first.0})).unwrap();
assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).is_overset());
s.execute("selection.set", &json!({"ids": [first.0]})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let layout = s.layout();
let port = designcraft_tools::thread::port_center(&s.doc().unwrap().doc, &layout, first, true, v.zoom).unwrap();
let ev = |k, p: designcraft_geom::Point| PointerEvent::new(k, p.x, p.y);
s.pointer(&ev(PointerKind::Down, port), v).unwrap();
s.pointer(&ev(PointerKind::Up, port), v).unwrap();
assert_eq!(s.cursor(port + designcraft_geom::Vec2::new(-2000.0, 0.0), Default::default(), v), designcraft_tools::Cursor::LoadedText);
// On the pasteboard left of the first spread.
let b = layout.slots[0].bounds;
let at = designcraft_geom::Point::new(b.x0 - 300.0, b.y0 + 100.0);
let undo = s.doc().unwrap().history.undo.len();
s.pointer(&ev(PointerKind::Down, at), v).unwrap();
s.pointer(&ev(PointerKind::Up, at), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
let frames = st.doc.story(sid).unwrap().frames.clone();
assert_eq!(frames.len(), 2);
let new = frames[1];
assert_eq!(st.selection.items, [new]);
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
assert!(cs.frames.iter().any(|f| f.frame == new && !f.range.is_empty()), "the text flows into the new frame");
let nb = s.doc().unwrap().doc.item(new).unwrap().bounds();
assert!((nb.y0 - layout.to_spread(designcraft_doc::SpreadRef::Doc(0), at).y).abs() < 1e-6, "{nb:?}");
}
/// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units).
fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) {
use designcraft_tools::{PointerEvent, PointerKind};
let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at);
s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap();
}
#[test]
fn text_frame_resize_snaps_to_the_margins() {
use designcraft_geom::{Point, Vec2};
for zoom in [1.0, 0.5] {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0);
s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(zoom);
let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
// Right handle to 1.5 pt short of the right margin.
drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1);
// Bottom handle to 1.5 pt past the bottom margin.
drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1);
// Top-left corner handle onto the top and left margins.
drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}");
}
}
#[test]
fn type_tool_frames_snap_to_the_margins() {
use designcraft_geom::Point;
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
s.set_tool("type");
let v = ViewInfo::at_zoom(1.0);
let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y));
// From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin.
let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5);
let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5);
let before = s.doc().unwrap().doc.spreads[0].items.len();
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();
let items = &s.doc().unwrap().doc.spreads[0].items;
assert_eq!(items.len(), before + 1, "a frame was drawn");
let r = items.last().unwrap().bounds();
assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}");
assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1);
}
+112
View File
@@ -91,6 +91,92 @@ fn magazine_round_trips_through_idml() {
assert_eq!(items(&again.spreads).len(), items(&back.spreads).len());
}
/// A one-page IDML package: two threaded frames side by side, 19 lines each (10 pt on 12 pt),
/// paragraphs of `lines` forced-break lines in style `Body`, which carries `body_attrs`.
fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec<u8> {
use std::io::Write;
let paras: String = lines
.iter()
.enumerate()
.map(|(p, &n)| {
let text = (0..n).map(|k| format!("P{p} line {k}")).collect::<Vec<_>>().join("\u{2028}");
format!(
r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>{text}</Content>{}</CharacterStyleRange></ParagraphStyleRange>"#,
if p + 1 < lines.len() { "<Br/>" } else { "" }
)
})
.collect();
let frame = |me: &str, x: f64, prev: &str, next: &str| {
let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<TextFrame Self="{me}" ParentStory="s1" PreviousTextFrame="{prev}" NextTextFrame="{next}" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"#
)
};
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d">
<DocumentPreference PageWidth="450" PageHeight="312" FacingPages="false"/>
<RootCharacterStyleGroup Self="rc"><CharacterStyle Self="CharacterStyle/$ID/[No character style]" Name="$ID/[No character style]"/></RootCharacterStyleGroup>
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" PointSize="10" {body_attrs}><Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn><Leading type="unit">12</Leading></Properties></ParagraphStyle>
</RootParagraphStyleGroup>
<Story Self="s1">{paras}</Story>
<Spread Self="sp1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 312 450" ItemTransform="1 0 0 1 0 0"/>{}{}</Spread>
</Document>"#,
frame("t1", 36.0, "n", "t2"),
frame("t2", 234.0, "t1", "n")
);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
w.start_file("mimetype", stored).unwrap();
w.write_all(b"application/vnd.adobe.indesign-idml-package").unwrap();
w.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(designmap.as_bytes()).unwrap();
w.finish().unwrap().into_inner()
}
/// Lines of paragraph `para` in each frame of the story.
fn para_lines_per_frame(d: &Document, para: usize) -> Vec<usize> {
let sid = *d.stories.keys().next().unwrap();
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
cs.frames.iter().map(|f| f.lines.iter().filter(|l| l.para == para).count()).collect()
}
/// Keep Lines Together without a mode in the IDML: InDesign's default, at start/end of paragraph.
/// A 10-line paragraph starting 7 lines above the first frame's bottom (the last baseline may sit
/// on the bottom edge) splits 7/3.
#[test]
fn idml_keep_lines_without_mode_split_at_start_and_end() {
let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#;
let d = designcraft_idml::import_idml(&keep_lines_idml(keep, &[13, 10, 2])).unwrap();
assert_eq!(para_lines_per_frame(&d, 0), [13, 0]);
assert_eq!(para_lines_per_frame(&d, 1), [7, 3]);
// All lines in paragraph, written explicitly: the paragraph moves whole.
let d = designcraft_idml::import_idml(&keep_lines_idml(&format!(r#"{keep} KeepAllLinesTogether="true""#), &[13, 10, 2])).unwrap();
assert_eq!(para_lines_per_frame(&d, 1), [0, 10]);
}
/// A document saved in format 1 with Keep Lines Together and no mode still keeps all lines
/// together after loading; saved now, it keeps lines at start/end of paragraph.
#[test]
fn format_1_keep_lines_without_mode_lays_out_all_lines() {
use std::io::Write;
let d = designcraft_idml::import_idml(&keep_lines_idml(r#"KeepLinesTogether="true""#, &[13, 10, 2])).unwrap();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap();
w.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
let v1 = designcraft_format::load(&w.finish().unwrap().into_inner()).unwrap();
assert_eq!(para_lines_per_frame(&v1, 1), [0, 10]);
let now = designcraft_format::load(&designcraft_format::save(&d).unwrap()).unwrap();
assert_eq!(para_lines_per_frame(&now, 1), [7, 3]);
}
#[test]
fn idml_commands() {
let mut s = Session::new();
@@ -251,3 +337,29 @@ fn named_lists_round_trip_through_idml() {
assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps"));
assert_eq!(st.paras[0].para.start_at, Some(Some(5)));
}
#[test]
fn type_para_sets_a_drop_cap_that_survives_idml() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.character.create", &json!({"name": "Initial", "chars": {"fontStyle": "Bold"}})).unwrap();
let text = "Once upon a time there was a paragraph long enough to run beside its drop cap for a few lines and then some more.";
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
s.execute("type.para", &json!({"attrs": {"dropCapLines": 3, "dropCapChars": 1, "dropCapStyle": "Initial", "dropCapAlignLeft": false}})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default());
let lines = &cs.frames[0].lines;
let dc = &lines[0].glyphs[0];
assert_eq!(dc.byte, 0);
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "on line 3's baseline");
assert!(lines[0].drop_cap.is_some_and(|b| b.end == 1));
let p = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].para;
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_style.as_deref()), (Some(3), Some(1), Some("Initial")));
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
let p = &back.stories.values().find(|st| st.text.starts_with("Once")).unwrap().paras[0].para;
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_align_left), (Some(3), Some(1), Some(false)));
// IDML has no attribute for the drop cap's character style.
assert_eq!(p.drop_cap_style, None);
}
+91 -6
View File
@@ -103,10 +103,14 @@ pub struct FontFace {
pub upem: f64,
/// Ascender in font units (positive = up).
pub ascent: f64,
/// OS/2 typographic ascender in font units (the ascent when the font has no usable one): the
/// Ascent first baseline offset.
pub typo_ascent: f64,
/// Descender in font units (positive = down).
pub descent: f64,
/// Cap height and x height in font units (estimated from the ascent when the font has no OS/2
/// values).
/// Cap height and x height in font units (without plausible OS/2 values, the cap height is the
/// top of the H, and either is estimated from the ascent): the Cap Height and x Height first
/// baseline offsets.
pub cap_height: f64,
pub x_height: f64,
pub shaper: harfrust::ShaperData,
@@ -677,6 +681,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect();
let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) };
let m = f.metrics(Size::unscaled(), &location);
let upem = m.units_per_em.max(1) as f64;
let a = f.attributes();
let hb = harfrust::FontRef::from_index(data, index).ok()?;
let shaper = harfrust::ShaperData::new(&hb);
@@ -694,11 +699,18 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
style,
weight,
italic,
upem: m.units_per_em.max(1) as f64,
upem,
ascent: m.ascent as f64,
typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64),
descent: -(m.descent as f64),
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
cap_height: plausible(m.cap_height.map(f64::from), upem)
.or_else(|| {
// No OS/2 cap height: the top of the H.
let gid = f.charmap().map('H')?;
plausible(f.glyph_metrics(Size::unscaled(), &location).bounds(gid).map(|b| b.y_max as f64), upem)
})
.unwrap_or(m.ascent as f64 * 0.72),
x_height: plausible(m.x_height.map(f64::from), upem).unwrap_or(m.ascent as f64 * 0.5),
shaper,
coords,
location,
@@ -712,6 +724,24 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
})
}
/// A font's height above the baseline (font units) if it is plausible: above 0 and at most 4 em.
fn plausible(v: Option<f64>, upem: f64) -> Option<f64> {
v.filter(|v| *v > 0.0 && *v <= 4.0 * upem)
}
/// The OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and
/// the value is [`plausible`].
fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option<f64> {
use skrifa::raw::TableProvider;
let mut v = f.os2().ok()?.s_typo_ascender() as f64;
if !location.coords().is_empty()
&& let Ok(mvar) = f.mvar()
{
v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64());
}
plausible(Some(v), upem)
}
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
pub fn base_style(style: &str) -> &str {
style.split('{').next().unwrap_or(style).trim()
@@ -825,6 +855,16 @@ impl FontDb {
self.scoped(0).styles(family)
}
/// The shared fonts' weight and italic of a style of `family` (see [`ScopedFonts::traits_of`]).
pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) {
self.scoped(0).traits_of(family, style)
}
/// Style linking among the shared fonts (see [`ScopedFonts::linked_style`]).
pub fn linked_style(&self, family: &str, current: &str, bold: Option<bool>, italic: Option<bool>) -> Option<String> {
self.scoped(0).linked_style(family, current, bold, italic)
}
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
/// every face was already present).
pub fn add_font(&self, bytes: Vec<u8>) -> usize {
@@ -1218,6 +1258,51 @@ impl ScopedFonts<'_> {
/// Style names available for `family` (Regular first, then by weight).
pub fn styles(&self, family: &str) -> Vec<String> {
self.style_traits(family).into_iter().map(|t| t.2).collect()
}
/// Weight and italic of a style of `family`: from the font when it is known, else from the
/// style's name.
pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) {
let base = base_style(style);
self.style_traits(family)
.into_iter()
.find(|t| t.2.eq_ignore_ascii_case(base))
.map_or((style_weight(base), style_italic(base)), |(italic, weight, _)| (weight, italic))
}
/// Style linking: the style of `family` that sets bold (`bold`) and italic (`italic`) as asked
/// and keeps the other from `current` (Bold from Italic is Bold Italic). `None` when the
/// family has no such style.
pub fn linked_style(&self, family: &str, current: &str, bold: Option<bool>, italic: Option<bool>) -> Option<String> {
let (weight, cur_italic) = self.traits_of(family, current);
let want_italic = italic.unwrap_or(cur_italic);
let want_weight = match bold {
Some(true) => 700.0,
Some(false) => 400.0,
None => weight,
};
// Width words (Condensed, Extended…) of the current style are kept when the family allows.
const WIDTHS: &[&str] = &["condensed", "compressed", "narrow", "extended", "expanded", "wide"];
let widths = |s: &str| {
let n = norm(s);
WIDTHS.iter().filter(|w| n.contains(*w)).count()
};
let cur_widths = widths(current);
self.style_traits(family)
.into_iter()
.filter(|(i, w, _)| *i == want_italic && bold.is_none_or(|b| b == (*w >= 600.0)))
.min_by(|a, b| {
let key = |t: &(bool, f32, String)| ((t.1 - want_weight).abs(), usize::from(widths(&t.2) != cur_widths), t.2.len());
let (ka, kb) = (key(a), key(b));
ka.0.total_cmp(&kb.0).then(ka.1.cmp(&kb.1)).then(ka.2.cmp(&kb.2))
})
.map(|t| t.2)
}
/// `(italic, weight, name)` of each style of `family` (the document's, loaded, or installed),
/// Regular first, then by weight.
fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> {
let mut v: Vec<(bool, f32, String)> = self
.own()
.iter()
@@ -1233,7 +1318,7 @@ impl ScopedFonts<'_> {
}
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
v.dedup_by(|a, b| a.2 == b.2);
v.into_iter().map(|t| t.2).collect()
v
}
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
+16
View File
@@ -347,6 +347,22 @@ mod tests {
}
}
#[test]
fn style_linking_finds_bold_and_italic_in_the_family() {
let db = FontDb::with_font_dirs(vec![]);
let link = |cur: &str, bold: Option<bool>, italic: Option<bool>| db.linked_style("Source Serif 4", cur, bold, italic);
assert_eq!(link("Regular", Some(true), None).as_deref(), Some("Bold"));
assert_eq!(link("Italic", Some(true), None).as_deref(), Some("Bold Italic"), "keeps the italic");
assert_eq!(link("Bold", None, Some(true)).as_deref(), Some("Bold Italic"), "keeps the weight");
assert_eq!(link("Bold Italic", Some(false), None).as_deref(), Some("Italic"));
assert_eq!(link("Bold Italic", None, Some(false)).as_deref(), Some("Bold"));
assert_eq!(link("Semibold", Some(false), None).as_deref(), Some("Regular"));
assert_eq!(db.traits_of("Source Serif 4", "Bold Italic"), (700.0, true));
// Source Sans 3 is bundled without a bold italic.
assert_eq!(db.linked_style("Source Sans 3", "Italic", Some(true), None), None);
assert_eq!(db.linked_style("No Such Family", "Regular", Some(true), None), None);
}
#[test]
fn shaping_produces_clusters_and_advances() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
+178 -12
View File
@@ -5,7 +5,8 @@
//! - `assets/<id>.<ext>`: embedded placed files, byte-for-byte
//! - `meta.json`: format version and generator
//!
//! Older single-file JSON documents (assets as base64 in `assetData`) still open.
//! Older single-file JSON documents (assets as base64 in `assetData`) still open. Documents
//! from older format versions are upgraded on load so they lay out as they did.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -13,11 +14,11 @@ use std::collections::HashSet;
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use designcraft_doc::{AssetId, Document};
use designcraft_doc::{AssetId, Document, ParaAttrs, Story};
use serde_json::{Value, json};
pub const MIME: &str = "application/vnd.designcraft+zip";
pub const VERSION: u32 = 1;
pub const VERSION: u32 = 2;
#[derive(Debug, thiserror::Error)]
pub enum FormatError {
@@ -88,16 +89,16 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
f.read_to_end(&mut v).ok()?;
Some(v)
};
if let Some(meta) = read(&mut z, "meta.json")
&& let Ok(m) = serde_json::from_slice::<Value>(&meta)
&& let Some(v) = m.get("version").and_then(Value::as_u64)
&& v as u32 > VERSION
{
return Err(FormatError::TooNew(v as u32));
let version = read(&mut z, "meta.json")
.and_then(|meta| serde_json::from_slice::<Value>(&meta).ok())
.and_then(|m| m.get("version").and_then(Value::as_u64))
.map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX));
if version > VERSION {
return Err(FormatError::TooNew(version));
}
let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?;
let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?;
upgrade(&mut v);
upgrade_json(&mut v);
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?;
let names: Vec<String> = z.file_names().map(str::to_string).collect();
for name in names {
@@ -107,13 +108,61 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
Arc::make_mut(a).data = Arc::new(data);
}
}
upgrade(&mut doc, version);
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
}
/// Bring a document saved in format `from` up to [`VERSION`].
fn upgrade(doc: &mut Document, from: u32) {
if from < 2 {
// Format 1 laid out an unset Keep Lines Together mode as all lines in paragraph, and drop
// caps without Align Left Edge.
pin_para_default(doc, |a| &mut a.keep_all_lines, true);
pin_para_default(doc, |a| &mut a.drop_cap_align_left, false);
}
}
/// Pin a paragraph attribute to the value an older format resolved it to where nothing set it:
/// at the start of each paragraph style chain that leaves it unset, and on paragraphs whose style
/// is missing.
fn pin_para_default<T: Clone>(doc: &mut Document, field: fn(&mut ParaAttrs) -> &mut Option<T>, old: T) {
let unset = |a: &ParaAttrs| field(&mut a.clone()).is_none();
let roots: std::collections::HashSet<String> = doc
.styles
.paragraph
.iter()
.filter_map(|s| {
let chain = doc.styles.para_chain(&s.name);
if chain.iter().all(|c| unset(&c.para)) { chain.first().map(|c| c.name.clone()) } else { None }
})
.collect();
if !roots.is_empty() {
for s in doc.styles_mut().paragraph.iter_mut().filter(|s| roots.contains(&s.name)) {
*field(&mut s.para) = Some(old.clone());
}
}
let styles = doc.styles.clone();
let mut pin = |st: &mut Story| {
for p in st.paras.iter_mut().filter(|p| styles.para(&p.style).is_none()) {
let v = field(&mut p.para);
if v.is_none() {
*v = Some(old.clone());
}
}
};
for st in doc.stories.values_mut() {
let st = Arc::make_mut(st);
st.for_each_text_mut(&mut pin);
for n in &mut st.endnotes {
Arc::make_mut(n).text.for_each_text_mut(&mut pin);
}
}
}
fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
let mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?;
upgrade(&mut v);
upgrade_json(&mut v);
let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?;
if let Some(data) = v.get("assetData").and_then(Value::as_object) {
for (k, s) in data {
@@ -124,6 +173,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
}
}
}
upgrade(&mut doc, 1);
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
}
@@ -131,7 +181,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
/// Bring older documents' JSON up to the current model. Vertical Type was a text frame option
/// (`content.options.vertical`); it is the story's direction, which a story takes from its first
/// frame.
fn upgrade(doc: &mut Value) {
fn upgrade_json(doc: &mut Value) {
let mut vertical = HashSet::new();
vertical_frames(doc, &mut vertical, 0);
if vertical.is_empty() {
@@ -231,6 +281,122 @@ mod tests {
assert_eq!(back.stories, d.stories);
}
/// `doc` as format 1 wrote it.
fn save_v1(doc: &Document) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap();
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(&serde_json::to_vec(doc).unwrap()).unwrap();
z.finish().unwrap().into_inner()
}
fn para_style(name: &str, based_on: Option<&str>, keep_all_lines: Option<bool>) -> designcraft_doc::ParagraphStyle {
designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: based_on.map(str::to_string),
next_style: None,
para: designcraft_doc::ParaAttrs { keep_lines_together: Some(true), keep_all_lines, ..Default::default() },
chars: Default::default(),
shortcut: String::new(),
}
}
/// Format 1 resolved an unset Keep Lines Together mode as all lines: those documents still
/// do after loading, wherever the mode comes from. New documents keep lines at start/end.
#[test]
fn format_1_keep_lines_without_mode_stays_all_lines() {
let mut d = Document::new(&NewDocument::default());
let st = d.styles_mut();
st.paragraph.push(para_style("Root", None, None));
st.paragraph.push(para_style("Child", Some("Root"), None));
st.paragraph.push(para_style("Explicit", None, Some(false)));
st.paragraph.push(para_style("Under explicit", Some("Explicit"), None));
st.paragraph.push(para_style("Loop A", Some("Loop B"), None));
st.paragraph.push(para_style("Loop B", Some("Loop A"), None));
let lid = d.default_layer();
let names = [designcraft_doc::story::BASIC_PARAGRAPH, "Root", "Child", "Explicit", "Under explicit", "Loop A", "Loop B", "Missing"];
let text = names.join("\n");
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, &text, ParaFormat::default()).unwrap();
{
let s = d.story_mut(sid).unwrap();
for (p, name) in s.paras.iter_mut().zip(names) {
p.style = name.into();
p.para.keep_lines_together = Some(true);
}
s.insert_note(1, "Note", ParaFormat { style: "Missing".into(), ..Default::default() });
}
let back = load(&save_v1(&d)).unwrap();
let resolve = |f: &ParaFormat| back.styles.resolve_para(f).0;
let story = back.story(sid).unwrap();
for (p, name) in story.paras.iter().zip(names) {
let pp = resolve(p);
assert!(pp.keep_lines_together);
assert_eq!(pp.keep_all_lines, !name.contains("xplicit"), "{name}");
}
assert!(resolve(&story.notes[0].text.paras[0]).keep_all_lines, "footnote with a missing style");
assert!(back.styles.resolve_para_style("Loop A").0.keep_all_lines && back.styles.resolve_para_style("Loop B").0.keep_all_lines);
// Pinned where the chains start; explicit and inheriting styles are left as they were.
assert_eq!(back.styles.para("Root").unwrap().para.keep_all_lines, Some(true));
assert_eq!(back.styles.para("Child").unwrap().para.keep_all_lines, None);
assert_eq!(back.styles.para("Under explicit").unwrap().para.keep_all_lines, None);
// The current format keeps the new default: at start/end of paragraph.
let now = load(&save(&d).unwrap()).unwrap();
let p = &now.story(sid).unwrap().paras[0];
assert!(!now.styles.resolve_para(p).0.keep_all_lines);
assert!(!designcraft_doc::ParaProps::default().keep_all_lines);
}
/// Format 1 wrote paragraph rules, text frame options and numbered lists in full, so their
/// old defaults (black rules, auto-size from the top centre, lists continuing across stories)
/// load as saved. New documents take InDesign's.
#[test]
fn format_1_rules_frames_and_lists_load_as_saved() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let old_rule = designcraft_doc::Rule { on: true, color: designcraft_doc::color::swatch::BLACK.into(), ..Default::default() };
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "Ruled", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(old_rule.clone());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.auto_size_ref = 1;
d.settings.lists.push(designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: true });
let back = load(&save_v1(&d)).unwrap();
assert_eq!(back.story(sid).unwrap().paras[0].para.rule_above, Some(old_rule));
assert_eq!(back.item(fid).unwrap().text_frame().unwrap().options.auto_size_ref, 1);
assert!(back.settings.lists[0].continue_across_stories);
assert_eq!(designcraft_doc::Rule::default().color, designcraft_doc::TEXT_COLOR);
assert_eq!(designcraft_doc::TextFrameOptions::default().auto_size_ref, 4);
assert!(!designcraft_doc::NumberedList::default().continue_across_stories);
}
/// Format 1 set drop caps without Align Left Edge: those documents still do where nothing set
/// it. New documents align the drop cap's left edge.
#[test]
fn format_1_drop_caps_stay_unaligned() {
let mut d = Document::new(&NewDocument::default());
let st = d.styles_mut();
st.paragraph.push(para_style("Opener", None, None));
st.paragraph.push(designcraft_doc::ParagraphStyle {
para: designcraft_doc::ParaAttrs { drop_cap_align_left: Some(true), ..Default::default() },
..para_style("Aligned", None, None)
});
let lid = d.default_layer();
let (_, sid) =
d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "One\nTwo\nThree", ParaFormat::default()).unwrap();
{
let s = d.story_mut(sid).unwrap();
for (p, name) in s.paras.iter_mut().zip(["Opener", "Aligned", "Missing"]) {
p.style = name.into();
p.para.drop_cap_lines = Some(2);
p.para.drop_cap_chars = Some(1);
}
}
let back = load(&save_v1(&d)).unwrap();
let aligned: Vec<bool> = back.story(sid).unwrap().paras.iter().map(|p| back.styles.resolve_para(p).0.drop_cap_align_left).collect();
assert_eq!(aligned, [false, true, false]);
let now = load(&save(&d).unwrap()).unwrap();
assert!(now.styles.resolve_para(&now.story(sid).unwrap().paras[0]).0.drop_cap_align_left);
}
/// The object of the item `id` in a document's JSON.
fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> {
if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() {
+26
View File
@@ -185,6 +185,32 @@ mod tests {
assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}");
}
/// InDesign draws a 28.35 pt radius on a 51.02 pt tall frame as 25.51 (half the shorter side),
/// as a circular arc (handles 0.5523 × the radius).
#[test]
fn radius_is_clamped_to_half_the_shorter_side() {
let r = rectangle(Rect::new(0.0, 0.0, 200.0, 51.02));
let bp = apply(&r, &CornerOptions::uniform(CornerShape::Rounded, 28.35));
let half = 51.02 / 2.0;
let mut arcs = 0;
let mut last = Point::ZERO;
for el in bp.elements() {
match *el {
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => last = p,
kurbo::PathEl::CurveTo(c1, c2, p) => {
assert!(((p - last).x.abs() - half).abs() < 1e-9 && ((p - last).y.abs() - half).abs() < 1e-9, "{last:?} → {p:?}");
assert!(((c1 - last).hypot() - half * crate::shapes::KAPPA).abs() < 1e-9);
assert!(((c2 - p).hypot() - half * crate::shapes::KAPPA).abs() < 1e-9);
arcs += 1;
last = p;
}
_ => {}
}
}
assert_eq!(arcs, 4);
assert!((crate::shapes::KAPPA - 0.5523).abs() < 1e-4);
}
#[test]
fn size_is_clamped_to_half_edge() {
let r = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
+16 -1
View File
@@ -1305,6 +1305,14 @@ impl<'a> Ex<'a> {
n!(space_after, "SpaceAfter");
n!(drop_cap_lines, "DropCapLines");
n!(drop_cap_chars, "DropCapCharacters");
// The drop cap's character style isn't written: IDML has no attribute for it (InDesign
// applies one through a Dropcap nested style).
if a.drop_cap_align_left.is_some() || a.drop_cap_scale_descenders.is_some() {
let d = designcraft_doc::ParaProps::default();
let bits = u32::from(a.drop_cap_align_left.unwrap_or(d.drop_cap_align_left))
| u32::from(a.drop_cap_scale_descenders.unwrap_or(d.drop_cap_scale_descenders)) << 1;
el.set("DropcapDetail", bits);
}
if let Some(g) = a.grid_align {
match g {
designcraft_doc::GridAlign::None => el.set("GridAlignment", "None"),
@@ -1352,6 +1360,9 @@ impl<'a> Ex<'a> {
if let Some(v) = a.single_word_justify {
el.set("SingleWordJustification", names::align_out(v));
}
if let Some(v) = a.kashidas {
el.set("Kashidas", names::kashidas_out(v));
}
n!(keep_with_next, "KeepWithNext");
b!(keep_lines_together, "KeepLinesTogether");
b!(keep_all_lines, "KeepAllLinesTogether");
@@ -1442,7 +1453,11 @@ impl<'a> Ex<'a> {
el.set(&format!("{k}Offset"), num(r.offset));
el.set(&format!("{k}LeftIndent"), num(r.left_indent));
el.set(&format!("{k}RightIndent"), num(r.right_indent));
props.push(p(&format!("{k}Color"), "object", self.sw(&r.color)));
props.push(if r.color == designcraft_doc::TEXT_COLOR {
p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR)
} else {
p(&format!("{k}Color"), "object", self.sw(&r.color))
});
}
fn stroke_attrs(&self, el: &mut El, s: &designcraft_doc::Stroke) {
+103 -68
View File
@@ -157,9 +157,12 @@ fn parse_point(s: Option<&str>) -> Option<Point> {
(v.len() >= 2).then(|| Point::new(v[0], v[1]))
}
/// IDML tint (percent, `-1` = default) → 0..1.
/// InDesign's corner size where a file gives a corner shape without one.
const INDESIGN_CORNER_SIZE: f64 = 12.0;
/// IDML tint (percent; `-1` = the colour's own tint, 100 %) → 0..1.
fn tint(v: Option<f64>) -> Option<f32> {
v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 })
v.map(|t| if t < 0.0 || t.is_nan() { 1.0 } else { (t / 100.0).min(1.0) as f32 })
}
impl<'r> Importer<'r> {
@@ -742,7 +745,7 @@ impl<'r> Importer<'r> {
let name = e.get("Name").unwrap_or("").to_string();
if !name.is_empty() && !name.starts_with("$ID/") && !self.lists.iter().any(|l| l.name == name) {
self.lists
.push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") != Some("false") });
.push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") == Some("true") });
}
}
let mut paras: Vec<(String, El)> = Vec::new();
@@ -856,18 +859,7 @@ impl<'r> Importer<'r> {
}
ts.border = Some(b);
}
if let Some(first) = e.num("StartRowFillCount").filter(|n| *n > 0.0) {
ts.alt_rows = Some(AltFills {
first: first as u32,
first_color: e.get("StartRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
first_tint: tint(e.num("StartRowFillTint")).unwrap_or(1.0),
next: e.num("EndRowFillCount").unwrap_or(1.0) as u32,
next_color: e.get("EndRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
next_tint: tint(e.num("EndRowFillTint")).unwrap_or(1.0),
skip_first: 0,
skip_last: 0,
});
}
ts.alt_rows = self.alt_fills(e, "Row").map(|a| AltFills { skip_first: 0, skip_last: 0, ..a });
ts.space_before = e.num("SpaceBefore");
ts.space_after = e.num("SpaceAfter");
match self.styles.table.iter_mut().find(|c| c.name == name) {
@@ -896,8 +888,11 @@ impl<'r> Importer<'r> {
let paragraph_style = enabled("EnableParagraphStyle", "AppliedParagraphStyle")
.then(|| e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)))
.flatten();
let text_frame =
if e.get("EnableTextFrameGeneralOptions") == Some("true") { e.find("TextFramePreference").map(text_frame_options) } else { None };
let text_frame = if e.get("EnableTextFrameGeneralOptions") == Some("true") {
e.find("TextFramePreference").map(|t| text_frame_options(&[t]))
} else {
None
};
let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame };
match self.styles.object.iter_mut().find(|o| o.name == name) {
Some(slot) => *slot = s,
@@ -1014,7 +1009,7 @@ impl<'r> Importer<'r> {
o.span_columns = e.boolean("EnableStraddling").unwrap_or(false);
o.rule.on = e.boolean("RuleOn").unwrap_or(true);
o.rule.weight = e.num("RuleLineWeight").unwrap_or(1.0);
o.rule.tint = (e.num("RuleTint").unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
o.rule.tint = tint(e.num("RuleTint")).unwrap_or(1.0);
o.rule.left_indent = e.num("RuleLeftIndent").unwrap_or(0.0);
o.rule.width = e.num("RuleWidth").unwrap_or(72.0);
o.rule.offset = e.num("RuleOffset").unwrap_or(0.0);
@@ -1096,7 +1091,7 @@ impl<'r> Importer<'r> {
for k in ["Underline", "StrikeThru"] {
let num = |n: &str| e.num(&format!("{k}{n}")).filter(|v| *v > -9000.0);
let color = e.prop(&format!("{k}Color")).map(|r| self.swatch_ref(r.trim())).filter(|r| r != "Text Color" && !r.is_empty());
let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32);
let tint = tint(e.num(&format!("{k}Tint")));
if k == "Underline" {
a.underline_weight = num("Weight").map(Some);
a.underline_offset = num("Offset").map(Some);
@@ -1251,6 +1246,13 @@ impl<'r> Importer<'r> {
a.space_after = e.num("SpaceAfter");
a.drop_cap_lines = u("DropCapLines");
a.drop_cap_chars = u("DropCapCharacters");
a.drop_cap_style = e.prop("DropCapStyle").map(|r| self.char_style_ref(r.trim()));
// DropcapDetail bits: 1 = Align Left Edge, 2 = Scale for Descenders.
if let Some(v) = e.num("DropcapDetail").filter(|v| v.is_finite() && *v >= 0.0) {
let bits = v as u32;
a.drop_cap_align_left = Some(bits & 1 != 0);
a.drop_cap_scale_descenders = Some(bits & 2 != 0);
}
if let Some(g) = e.prop("GridAlignment") {
a.grid_align = Some(if g == "None" {
GridAlign::None
@@ -1284,6 +1286,7 @@ impl<'r> Importer<'r> {
a.glyph_scale_max = frac("MaximumGlyphScaling");
a.auto_leading = frac("AutoLeading");
a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim()));
a.kashidas = e.prop("Kashidas").and_then(|v| names::kashidas_in(v.trim()));
a.keep_with_next = u("KeepWithNext");
a.keep_lines_together = e.boolean("KeepLinesTogether");
a.keep_all_lines = e.boolean("KeepAllLinesTogether");
@@ -1349,7 +1352,9 @@ impl<'r> Importer<'r> {
}
if let Some(c) = e.prop(&format!("{k}Color")) {
let c = c.trim();
if c.contains('/') {
if c == designcraft_doc::TEXT_COLOR {
r.color = c.to_string();
} else if c.contains('/') {
r.color = self.swatch_ref(c);
}
}
@@ -1488,24 +1493,8 @@ impl<'r> Importer<'r> {
t.options.space_after = e.num("SpaceAfter").unwrap_or(t.options.space_after);
t.options.repeat_header = e.get("HeaderBehavior") != Some("RepeatOnce");
t.options.repeat_footer = e.get("FooterBehavior") != Some("RepeatOnce");
for kind in ["Row", "Column"] {
let Some(first) = e.num(&format!("Start{kind}FillCount")).filter(|n| *n > 0.0) else { continue };
let alt = AltFills {
first: first as u32,
first_color: e.get(&format!("Start{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
first_tint: tint(e.num(&format!("Start{kind}FillTint"))).unwrap_or(1.0),
next: e.num(&format!("End{kind}FillCount")).unwrap_or(1.0) as u32,
next_color: e.get(&format!("End{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
next_tint: tint(e.num(&format!("End{kind}FillTint"))).unwrap_or(1.0),
skip_first: e.num(&format!("SkipFirstAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
skip_last: e.num(&format!("SkipLastAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
};
if kind == "Row" {
t.options.alt_rows = Some(alt);
} else {
t.options.alt_cols = Some(alt);
}
}
t.options.alt_rows = self.alt_fills(e, "Row");
t.options.alt_cols = self.alt_fills(e, "Column");
t.style = e
.get("AppliedTableStyle")
.map(|r| names::style_name_in(names::TABLE_BUILTINS, &unescape_id(r.trim_start_matches("TableStyle/"))))
@@ -1565,6 +1554,24 @@ impl<'r> Importer<'r> {
t
}
/// A table's or table style's alternating `kind` ("Row" or "Column") fills, when it has any;
/// unset values are InDesign's: the first Black 20 %, the next None.
fn alt_fills(&mut self, e: &El, kind: &str) -> Option<AltFills> {
let first = e.num(&format!("Start{kind}FillCount")).filter(|n| *n > 0.0)?;
let d = AltFills::default();
let count = |k: String, or: u32| e.num(&k).map_or(or, |v| v.max(0.0) as u32);
Some(AltFills {
first: first as u32,
first_color: e.get(&format!("Start{kind}FillColor")).map_or(d.first_color, |c| self.swatch_ref(c)),
first_tint: tint(e.num(&format!("Start{kind}FillTint"))).unwrap_or(d.first_tint),
next: count(format!("End{kind}FillCount"), d.next),
next_color: e.get(&format!("End{kind}FillColor")).map_or(d.next_color, |c| self.swatch_ref(c)),
next_tint: tint(e.num(&format!("End{kind}FillTint"))).unwrap_or(d.next_tint),
skip_first: count(format!("SkipFirstAlternatingFill{kind}s"), 0),
skip_last: count(format!("SkipLastAlternatingFill{kind}s"), 0),
})
}
fn walk_story(&mut self, e: &El, b: &mut StoryBuilder, pf: &ParaFormat, pchars: &CharAttrs, cf: &CharFormat, brk: Option<&str>) {
for n in &e.children {
match n {
@@ -1826,25 +1833,33 @@ impl<'r> Importer<'r> {
})
}
/// Resolve an item attribute, falling back to its object style chain.
fn attr_or_style(&self, e: &El, k: &str) -> Option<String> {
if let Some(v) = e.get(k) {
return Some(v.to_string());
}
/// An item's object style chain: its applied style, the styles that one is based on, then
/// `[None]` (cycle-safe).
fn object_style_chain(&self, e: &El) -> Vec<&El> {
let mut chain: Vec<&El> = Vec::new();
let mut os = e.get("AppliedObjectStyle").map(str::to_string);
let mut depth = 0;
while let Some(id) = os {
depth += 1;
if depth > 16 {
let Some(s) = self.object_els.get(&id) else { break };
if chain.len() >= 16 || chain.iter().any(|c| std::ptr::eq(*c, s)) {
break;
}
let Some(s) = self.object_els.get(&id) else { break };
if let Some(v) = s.get(k) {
return Some(v.to_string());
}
os = s.prop("BasedOn").map(|b| if b.starts_with("ObjectStyle/") { b } else { format!("ObjectStyle/{}", names::escape_id(&b)) });
chain.push(s);
os = s.prop("BasedOn").map(|b| {
let b = b.trim();
if b.starts_with("ObjectStyle/") { b.to_string() } else { format!("ObjectStyle/{}", names::escape_id(b)) }
});
}
None
if let Some(none) = self.object_els.get("ObjectStyle/$ID/[None]")
&& !chain.iter().any(|c| std::ptr::eq(*c, none))
{
chain.push(none);
}
chain
}
/// Resolve an item attribute, falling back to its object style chain.
fn attr_or_style(&self, e: &El, k: &str) -> Option<String> {
e.get(k).or_else(|| self.object_style_chain(e).into_iter().find_map(|s| s.get(k))).map(str::to_string)
}
fn stroke_from(&mut self, e: &El, item: Option<&El>) -> Stroke {
@@ -1950,19 +1965,21 @@ impl<'r> Importer<'r> {
it.stroke.weight = 1.0;
}
}
// Corners.
let cn = crate::export::corner_names(&it.path);
// The legacy all-corners attributes apply only when no per-corner attribute is present.
let per_corner = cn.iter().any(|n| e.get(&format!("{n}CornerOption")).is_some());
let legacy = if per_corner { (None, None) } else { (e.get("CornerOption"), e.num("CornerRadius")) };
let mut corners = CornerOptions::default();
for (i, n) in cn.iter().enumerate() {
let shape = e.get(&format!("{n}CornerOption")).or(legacy.0).map(names::corner_in).unwrap_or_default();
let size = e.num(&format!("{n}CornerRadius")).or(legacy.1).unwrap_or(0.0);
corners.corners[i] = Corner { shape, size };
}
if !corners.is_none() {
it.corners = corners;
// Corners: each corner's shape and radius come from the item, then its object style chain;
// at each level a per-corner attribute wins over the legacy all-corners one.
if tag != "Group" {
let levels: Vec<&El> = std::iter::once(e).chain(self.object_style_chain(e)).collect();
let corner =
|n: &str, what: &str| levels.iter().find_map(|s| s.get(&format!("{n}Corner{what}")).or_else(|| s.get(&format!("Corner{what}"))));
let mut corners = CornerOptions::default();
for (i, n) in crate::export::corner_names(&it.path).iter().enumerate() {
let shape = corner(n, "Option").map(names::corner_in).unwrap_or_default();
let size = corner(n, "Radius").and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(INDESIGN_CORNER_SIZE);
corners.corners[i] = Corner { shape, size };
}
if !corners.is_none() {
it.corners = corners;
}
}
// Transparency.
if let Some(t) = e.find("TransparencySetting") {
@@ -2042,7 +2059,9 @@ impl<'r> Importer<'r> {
sid
}
};
let mut options = e.find("TextFramePreference").map(text_frame_options).unwrap_or_default();
// Options the frame doesn't set come from its object style chain.
let prefs: Vec<&El> = std::iter::once(e).chain(self.object_style_chain(e)).filter_map(|s| s.find("TextFramePreference")).collect();
let mut options = text_frame_options(&prefs);
if let Some(g) = e.find("BaselineFrameGridOption")
&& g.get("UseCustomBaselineFrameGrid") == Some("true")
{
@@ -2333,7 +2352,9 @@ fn margins_of(m: &El) -> (Margins, Columns) {
)
}
fn text_frame_options(e: &El) -> TextFrameOptions {
/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it.
fn text_frame_options(prefs: &[&El]) -> TextFrameOptions {
let e = Prefs(prefs);
let mut o = TextFrameOptions::default();
if let Some(v) = e.num("TextColumnCount") {
o.columns = v.max(1.0) as u32;
@@ -2354,7 +2375,9 @@ fn text_frame_options(e: &El) -> TextFrameOptions {
o.column_width = v;
}
o.balance_columns = e.get("VerticalBalanceColumns") == Some("true");
if let Some(inset) = inset_spacing(e) {
if let Some(p) = prefs.iter().find(|p| p.prop("InsetSpacing").is_some())
&& let Some(inset) = inset_spacing(p)
{
o.inset = inset;
}
if let Some(v) = e.get("VerticalJustification") {
@@ -2379,6 +2402,18 @@ fn text_frame_options(e: &El) -> TextFrameOptions {
o
}
/// Attribute lookup over several elements: the first that has it.
struct Prefs<'a>(&'a [&'a El]);
impl Prefs<'_> {
fn get(&self, k: &str) -> Option<&str> {
self.0.iter().find_map(|e| e.get(k))
}
fn num(&self, k: &str) -> Option<f64> {
self.get(k).and_then(|v| v.trim().parse().ok())
}
}
/// A scalar applies to every edge; a list is top, left, bottom, right. Keep the
/// existing property-element precedence over the legacy scalar attribute, even
/// when the property is malformed. Finite negative insets remain supported.
+10
View File
@@ -447,6 +447,14 @@ pub fn auto_size_in(s: &str) -> AutoSize {
.find(|m| auto_size_out(*m) == s)
.unwrap_or(AutoSize::Off)
}
/// Insert Kashidas (`Kashidas`): InDesign's default, or off.
pub fn kashidas_out(on: bool) -> &'static str {
if on { "DefaultKashidas" } else { "KashidasOff" }
}
pub fn kashidas_in(s: &str) -> Option<bool> {
[true, false].into_iter().find(|on| kashidas_out(*on) == s)
}
/// Auto-size reference points in our 0..9 order (row-major, top-left first).
pub const REF_POINTS: [&str; 9] = [
"TopLeftPoint",
@@ -597,6 +605,7 @@ pub fn nested_until_out(u: &designcraft_doc::NestedUntil) -> (&'static str, Stri
N::ForcedLineBreak => e("ForcedLineBreak"),
N::EmSpace => e("EmSpaces"),
N::EnSpace => e("EnSpaces"),
N::Dropcap => e("Dropcap"),
N::Chars(c) => ("string", c.clone()),
}
}
@@ -615,6 +624,7 @@ pub fn nested_until_in(ty: &str, v: &str) -> designcraft_doc::NestedUntil {
"ForcedLineBreak" => N::ForcedLineBreak,
"EmSpaces" => N::EmSpace,
"EnSpaces" => N::EnSpace,
"Dropcap" => N::Dropcap,
_ => N::Words,
}
}
+513
View File
@@ -28,6 +28,20 @@ fn zip_files_plain() -> Vec<u8> {
w.finish().unwrap().into_inner()
}
/// The text of every part of the package `bytes` whose name starts with `prefix`.
fn zip_text(bytes: &[u8], prefix: &str) -> String {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = String::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
if f.name().starts_with(prefix) {
f.read_to_string(&mut out).unwrap();
}
}
out
}
/// A hand-written, minimal IDML document: one facing-pages spread with a right page, a
/// threaded story across two frames, a tint and a grouped paragraph style.
const DESIGNMAP: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -194,6 +208,376 @@ fn fixture_with_story(story: &str) -> Vec<u8> {
])
}
fn fixture_with_styles(styles: &str) -> Vec<u8> {
zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", styles),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", STORY),
])
}
/// Paragraph styles `Body` (based on the root) and `Loose` (no BasedOn), both with `attrs`.
fn keep_styles(attrs: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" {attrs}>
<Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Loose" Name="Loose" {attrs}/>
</RootParagraphStyleGroup>
</idPkg:Styles>"#
)
}
/// Keep Lines Together whose mode no style sets is InDesign's default: at start/end of paragraph.
/// An explicit mode still wins.
#[test]
fn keep_lines_mode_defaults_to_start_and_end() {
let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#;
for (attrs, all) in [(keep.to_string(), false), (format!(r#"{keep} KeepAllLinesTogether="true""#), true)] {
let d = import_idml_with(&fixture_with_styles(&keep_styles(&attrs)), &|_| None).unwrap();
for name in ["Body", "Loose"] {
let (pp, _) = d.styles.resolve_para_style(name);
assert_eq!((pp.keep_lines_together, pp.keep_all_lines, pp.keep_first, pp.keep_last), (true, all, 2, 2), "{name}: {attrs}");
}
}
}
type Resolved = (designcraft_doc::ParaProps, designcraft_doc::CharProps);
/// (IDML attribute, InDesign's default as IDML writes it, another value, the resolved field,
/// its value for InDesign's default, its value for the other value).
type AttrRow = (&'static str, &'static str, &'static str, fn(&Resolved) -> String, String, String);
macro_rules! attr_rows {
($($attr:literal: $default:literal => $dv:expr, $other:literal => $ov:expr, |$r:ident| $field:expr;)*) => {
vec![$(($attr, $default, $other, (|$r: &Resolved| format!("{:?}", $field)) as fn(&Resolved) -> String, format!("{:?}", $dv), format!("{:?}", $ov)),)*]
};
}
/// InDesign's defaults for a new document's [No Paragraph Style], for every paragraph and
/// character attribute the importer reads (except the font family: see below).
fn indesign_text_defaults() -> Vec<AttrRow> {
use designcraft_doc::{Align, Capitalization, Composer, Digits, GridAlign, Leading, ListType, Position, StartParagraph, TextDirection};
attr_rows! {
"Justification": "LeftAlign" => Align::Left, "CenterAlign" => Align::Center, |r| r.0.align;
"ParagraphDirection": "LeftToRightDirection" => TextDirection::LeftToRight, "RightToLeftDirection" => TextDirection::RightToLeft, |r| r.0.direction;
"LeftIndent": "0" => 0.0, "6" => 6.0, |r| r.0.left_indent;
"RightIndent": "0" => 0.0, "6" => 6.0, |r| r.0.right_indent;
"FirstLineIndent": "0" => 0.0, "6" => 6.0, |r| r.0.first_line_indent;
"LastLineIndent": "0" => 0.0, "6" => 6.0, |r| r.0.last_line_indent;
"SpaceBefore": "0" => 0.0, "6" => 6.0, |r| r.0.space_before;
"SpaceAfter": "0" => 0.0, "6" => 6.0, |r| r.0.space_after;
"DropCapLines": "0" => 0, "3" => 3, |r| r.0.drop_cap_lines;
"DropCapCharacters": "0" => 0, "1" => 1, |r| r.0.drop_cap_chars;
"DropcapDetail": "1" => (true, false), "2" => (false, true), |r| (r.0.drop_cap_align_left, r.0.drop_cap_scale_descenders);
"GridAlignment": "None" => GridAlign::None, "AlignToBaseline" => GridAlign::AllLines, |r| r.0.grid_align;
"Composer": "HL Composer" => Composer::Paragraph, "HL Single" => Composer::SingleLine, |r| r.0.composer;
"Hyphenation": "true" => true, "false" => false, |r| r.0.hyphenate;
"HyphenateWordsLongerThan": "5" => 5, "7" => 7, |r| r.0.hyph_min_word;
"HyphenateAfterFirst": "2" => 2, "3" => 3, |r| r.0.hyph_after_first;
"HyphenateBeforeLast": "2" => 2, "3" => 3, |r| r.0.hyph_before_last;
"HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit;
"HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone;
"HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized;
"HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word;
"HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column;
"HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight;
"MinimumWordSpacing": "80" => 0.8, "70" => 0.7, |r| r.0.word_space_min;
"DesiredWordSpacing": "100" => 1.0, "90" => 0.9, |r| r.0.word_space_desired;
"MaximumWordSpacing": "133" => 1.33, "150" => 1.5, |r| r.0.word_space_max;
"MinimumLetterSpacing": "0" => 0.0, "-5" => -0.05, |r| r.0.letter_space_min;
"DesiredLetterSpacing": "0" => 0.0, "1" => 0.01, |r| r.0.letter_space_desired;
"MaximumLetterSpacing": "0" => 0.0, "5" => 0.05, |r| r.0.letter_space_max;
"MinimumGlyphScaling": "100" => 1.0, "97" => 0.97, |r| r.0.glyph_scale_min;
"DesiredGlyphScaling": "100" => 1.0, "101" => 1.01, |r| r.0.glyph_scale_desired;
"MaximumGlyphScaling": "100" => 1.0, "103" => 1.03, |r| r.0.glyph_scale_max;
"AutoLeading": "120" => 1.2, "100" => 1.0, |r| r.0.auto_leading;
"SingleWordJustification": "FullyJustified" => Align::FullyJustified, "LeftAlign" => Align::Left, |r| r.0.single_word_justify;
"KeepWithNext": "0" => 0, "1" => 1, |r| r.0.keep_with_next;
"KeepLinesTogether": "false" => false, "true" => true, |r| r.0.keep_lines_together;
"KeepAllLinesTogether": "false" => false, "true" => true, |r| r.0.keep_all_lines;
"KeepFirstLines": "2" => 2, "3" => 3, |r| r.0.keep_first;
"KeepLastLines": "2" => 2, "3" => 3, |r| r.0.keep_last;
"StartParagraph": "Anywhere" => StartParagraph::Anywhere, "NextColumn" => StartParagraph::NextColumn, |r| r.0.start_paragraph;
"BulletsAndNumberingListType": "NoList" => ListType::None, "BulletList" => ListType::Bullets, |r| r.0.list_type;
"BalanceRaggedLines": "NoBalancing" => false, "FullyBalanced" => true, |r| r.0.balance_ragged;
"Kashidas": "DefaultKashidas" => true, "KashidasOff" => false, |r| r.0.kashidas;
"RuleAbove": "false" => false, "true" => true, |r| r.0.rule_above.on;
"RuleAboveColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_above.color;
"RuleAboveTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_above.tint;
"RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on;
"RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color;
"RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint;
"ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on;
"ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color;
"ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint;
"ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on;
"ParagraphBorderColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.border_color;
"ParagraphBorderTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.border_tint;
"FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style;
"PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size;
"Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading;
"KerningMethod": "$ID/Metrics" => designcraft_doc::Kerning::Metrics, "$ID/Optical" => designcraft_doc::Kerning::Optical, |r| r.1.kerning;
"Tracking": "0" => 0.0, "20" => 20.0, |r| r.1.tracking;
"HorizontalScale": "100" => 1.0, "90" => 0.9, |r| r.1.h_scale;
"VerticalScale": "100" => 1.0, "90" => 0.9, |r| r.1.v_scale;
"BaselineShift": "0" => 0.0, "2" => 2.0, |r| r.1.baseline_shift;
"Skew": "0" => 0.0, "10" => 10.0, |r| r.1.skew;
"FillColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.1.fill;
"FillTint": "-1" => 1.0, "50" => 0.5, |r| r.1.fill_tint;
"StrokeColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.1.stroke;
"StrokeTint": "-1" => 1.0, "50" => 0.5, |r| r.1.stroke_tint;
"StrokeWeight": "1" => 1.0, "2" => 2.0, |r| r.1.stroke_weight;
"Capitalization": "Normal" => Capitalization::Normal, "AllCaps" => Capitalization::AllCaps, |r| r.1.capitalization;
"Position": "Normal" => Position::Normal, "Superscript" => Position::Superscript, |r| r.1.position;
"Underline": "false" => false, "true" => true, |r| r.1.underline;
"StrikeThru": "false" => false, "true" => true, |r| r.1.strikethrough;
"UnderlineTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.underline_tint;
"StrikeThruTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.strikethrough_tint;
"Ligatures": "true" => true, "false" => false, |r| r.1.ligatures;
"NoBreak": "false" => false, "true" => true, |r| r.1.no_break;
"OTFContextualAlternate": "true" => true, "false" => false, |r| designcraft_doc::otf::is_on(&r.1.otf_features, "calt");
"DigitsType": "DefaultDigits" => Digits::Default, "ArabicDigits" => Digits::Arabic, |r| r.1.digits;
"AppliedLanguage": "$ID/English: USA" => "English: USA", "$ID/German: 2006 Reform" => "German: 2006 Reform", |r| r.1.language;
}
}
/// [No paragraph style] with `root` attributes and `Body`, based on it, with `body` attributes.
fn text_styles(root: &str, body: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" {root}/>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" {body}>
<Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
</RootParagraphStyleGroup>
</idPkg:Styles>"#
)
}
fn resolve_body(root: &str, body: &str) -> Resolved {
let d = import_idml_with(&fixture_with_styles(&text_styles(root, body)), &|_| None).unwrap();
d.styles.resolve_para_style("Body")
}
/// Text attributes an IDML file leaves unset are InDesign's defaults; written ones win, and a
/// file that writes every default (as InDesign's own exports do) imports the same.
#[test]
fn absent_text_attributes_take_indesign_defaults() {
let rows = indesign_text_defaults();
let all = |pick: fn(&AttrRow) -> &str| rows.iter().map(|r| format!(r#"{}="{}""#, r.0, pick(r))).collect::<Vec<_>>().join(" ");
let (defaults, others) = (all(|r| r.1), all(|r| r.2));
let omitted = resolve_body("", "");
let written = resolve_body(&defaults, "");
for (attr, _, _, field, want, _) in &rows {
assert_eq!(field(&omitted), *want, "{attr} omitted");
assert_eq!(field(&written), *want, "{attr} written as InDesign's default");
}
assert_eq!(omitted, written);
for (root, body) in [(others.as_str(), ""), ("", others.as_str())] {
let r = resolve_body(root, body);
for (attr, _, _, field, _, want) in &rows {
assert_eq!(field(&r), *want, "{attr} written on the {}", if root.is_empty() { "style" } else { "root" });
}
}
// Product difference: InDesign's default face isn't available, so unset fonts are DesignCraft's.
assert_eq!(omitted.1.font_family, designcraft_doc::CharProps::default().font_family);
}
/// A tint of -1 is the colour's own tint (100 %): written on a style, it replaces its parent's.
/// Tints are never negative or above 100 %.
#[test]
fn minus_one_tint_is_the_colours_own() {
let tints = |v: &str| {
["UnderlineTint", "StrikeThruTint", "FillTint", "StrokeTint", "ParagraphShadingTint", "ParagraphBorderTint", "RuleAboveTint", "RuleBelowTint"]
.map(|k| format!(r#"{k}="{v}""#))
.join(" ")
+ r#" RuleAbove="true" RuleBelow="true""#
};
for (body, want) in [("-1", 1.0), ("150", 1.0)] {
let r = resolve_body(&tints("50"), &tints(body));
let got = [
r.1.underline_tint,
r.1.strikethrough_tint,
r.1.fill_tint,
r.1.stroke_tint,
r.0.shading_tint,
r.0.border_tint,
r.0.rule_above.tint,
r.0.rule_below.tint,
];
assert_eq!(got, [want; 8], "{body}");
}
// Footnote rules, table fills and strokes, cell fills.
let prefs = PREFS.replace("</idPkg:Preferences>", r#"<FootnoteOption RuleTint="-1"/></idPkg:Preferences>"#);
let story = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table Self="tb" StartRowFillCount="1" StartRowFillTint="-1" EndRowFillTint="-1" TopBorderStrokeTint="-1">
<Row Self="r0" Name="0"/><Column Self="c0" Name="0"/><Cell Self="ce" Name="0:0" FillColor="Color/Black" FillTint="-1" TopEdgeStrokeTint="-1"/></Table></CharacterStyleRange></ParagraphStyleRange></Story>
</idPkg:Story>"#;
let d = import_idml_with(
&zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", &prefs),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", story),
]),
&|_| None,
)
.unwrap();
assert_eq!(d.footnote_options.rule.tint, 1.0);
let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
let alt = t.options.alt_rows.as_ref().unwrap();
assert_eq!((alt.first_tint, alt.next_tint, t.options.border.tint), (1.0, 1.0, 1.0));
let c = t.cell(0, 0).unwrap();
assert_eq!((c.fill_tint, c.strokes[0].tint), (1.0, 1.0));
}
/// Rules in Text Color take the colour of the paragraph's text; they round-trip as Text Color.
#[test]
fn text_color_rules_round_trip() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let rule = designcraft_doc::Rule { on: true, ..Default::default() };
assert_eq!(rule.color, designcraft_doc::TEXT_COLOR);
let para = designcraft_doc::ParaAttrs {
rule_above: Some(rule.clone()),
rule_below: Some(designcraft_doc::Rule { color: "[Black]".into(), ..rule }),
kashidas: Some(false),
..Default::default()
};
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Ruled", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().paras[0].para = para.clone();
let bytes = export_idml(&d);
let story = zip_text(&bytes, "Stories/");
assert!(story.contains(r#"<RuleAboveColor type="string">Text Color</RuleAboveColor>"#), "{story}");
assert!(story.contains(r#"Kashidas="KashidasOff""#), "{story}");
let back = import_idml(&bytes).unwrap();
let p = &back.stories.values().find(|s| s.text == "Ruled").unwrap().paras[0];
assert_eq!((p.para.rule_above.as_ref(), p.para.rule_below.as_ref()), (para.rule_above.as_ref(), para.rule_below.as_ref()));
assert_eq!(p.para.kashidas, Some(false));
}
/// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways.
#[test]
fn basic_paragraph_is_normal_paragraph_style() {
let d = import_idml_with(&fixture(), &|_| None).unwrap();
let basic = d.styles.para(st::BASIC_PARAGRAPH).unwrap();
assert_eq!(basic.based_on.as_deref(), Some(designcraft_doc::NO_PARA_STYLE));
assert_eq!(d.styles.para("Text/Body").unwrap().based_on.as_deref(), Some(st::BASIC_PARAGRAPH));
let s = d.stories.values().find(|s| s.text.starts_with("Hello")).unwrap();
assert_eq!(s.paras[1].style, st::BASIC_PARAGRAPH);
assert!(d.styles.paragraph.iter().all(|p| !p.name.contains("NormalParagraphStyle")));
let bytes = export_idml(&d);
let styles = zip_text(&bytes, "Resources/Styles.xml");
assert!(styles.contains(r#"Self="ParagraphStyle/$ID/NormalParagraphStyle" Name="$ID/NormalParagraphStyle""#), "{styles}");
assert!(zip_text(&bytes, "Stories/").contains(r#"AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle""#));
let back = import_idml(&bytes).unwrap();
assert_eq!(back.styles.paragraph.iter().filter(|p| p.name == st::BASIC_PARAGRAPH).count(), 1);
}
/// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults
/// for a new document.
#[test]
fn absent_item_and_document_attributes_take_indesign_defaults() {
use designcraft_doc::{Cap, FirstBaseline, Join, StrokeAlign, VerticalJustification};
use designcraft_geom::corners::CornerShape;
let path = |x: f64| {
let pts: String = [(x, 0.0), (x, 50.0), (x + 50.0, 50.0), (x + 50.0, 0.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties>"#
)
};
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d">
<NumberingList Self="NumberingList/Steps" Name="Steps"/>
<RootTableStyleGroup Self="rts"><TableStyle Self="TableStyle/Banded" Name="Banded" StartRowFillCount="1"/></RootTableStyleGroup>
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table Self="tb" StartRowFillCount="1" StartColumnFillCount="2"><Row Self="r0" Name="0"/><Column Self="c0" Name="0"/><Cell Self="ce" Name="0:0"/></Table></CharacterStyleRange></ParagraphStyleRange></Story>
<Spread Self="sp1"><Page Self="p1"/>
<Rectangle Self="plain">{}</Rectangle>
<Rectangle Self="round" TopLeftCornerOption="RoundedCorner" TopRightCornerOption="RoundedCorner" BottomLeftCornerOption="RoundedCorner" BottomRightCornerOption="RoundedCorner">{}</Rectangle>
<Rectangle Self="legacy" CornerOption="RoundedCorner">{}</Rectangle>
<Rectangle Self="sized" TopLeftCornerOption="RoundedCorner" TopLeftCornerRadius="5">{}</Rectangle>
<TextFrame Self="tf" ParentStory="s1">{}</TextFrame>
</Spread>
</Document>"#,
path(0.0),
path(100.0),
path(200.0),
path(300.0),
path(400.0)
);
let d = import_idml_with(&zip_files(&[("designmap.xml", &designmap)]), &|_| None).unwrap();
// Document Setup, margins and columns, grids, Advanced Type, transparency, black.
let s = &d.settings;
assert_eq!((s.page_width, s.page_height, s.facing_pages, s.intent), (612.0, 792.0, true, designcraft_doc::Intent::Print));
assert_eq!((s.bleed, s.slug), ([0.0; 4], [0.0; 4]));
let page = &d.spreads[0].pages[0];
assert_eq!((page.margins.top, page.margins.bottom, page.margins.inside, page.margins.outside), (36.0, 36.0, 36.0, 36.0));
assert_eq!((page.columns.count, page.columns.gutter), (1, 12.0));
assert_eq!((s.baseline_grid.start, s.baseline_grid.increment, s.baseline_grid.view_threshold), (36.0, 12.0, 0.75));
assert_eq!((s.grid.horizontal, s.grid.vertical, s.grid.subdivisions, s.grid.in_back), (72.0, 72.0, 8, true));
let a = &s.advanced_type;
assert_eq!((a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position), (58.3, 33.3, 58.3, 33.3));
assert_eq!((s.blend_space, s.overprint_black, s.keyboard_increment), (designcraft_doc::BlendSpace::Cmyk, true, 1.0));
// Fill, stroke, transparency, wrap and corners of a frame with no attributes.
let item = |n: usize| d.spreads[0].items[n].clone();
let plain = item(0);
assert!(plain.fill.is_none() && plain.stroke.is_none());
let st = &plain.stroke;
assert_eq!((st.weight, st.miter_limit, st.cap, st.join, st.align), (1.0, 4.0, Cap::Butt, Join::Miter, StrokeAlign::Center));
assert_eq!((plain.opacity, plain.blend, plain.wrap.mode), (1.0, designcraft_color::BlendMode::Normal, designcraft_doc::WrapMode::None));
assert!(plain.corners.is_none());
// A corner shape without a size: 12 pt.
for n in [1, 2] {
assert_eq!(item(n).corners.corners.map(|c| (c.shape, c.size)), [(CornerShape::Rounded, 12.0); 4], "item {n}");
}
assert_eq!(item(3).corners.corners[0].size, 5.0);
// Text Frame Options.
let tf = item(4);
let o = &tf.text_frame().unwrap().options;
assert_eq!((o.columns, o.gutter, o.inset, o.balance_columns, o.ignore_wrap), (1, 12.0, [0.0; 4], false, false));
assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Top, FirstBaseline::Ascent, 0.0));
assert_eq!((o.auto_size, o.auto_size_ref), (designcraft_doc::AutoSize::Off, 4), "auto-size from the centre");
// Numbered lists don't continue across stories.
assert_eq!(d.settings.lists, vec![designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: false }]);
// Table and cell options.
let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!((t.options.space_before, t.options.space_after, t.options.repeat_header), (4.0, -4.0, true));
assert_eq!((t.options.border.weight, t.options.border.color.as_str()), (1.0, "[Black]"));
assert_eq!((t.rows[0].mode, t.rows[0].height), (designcraft_doc::RowHeightMode::AtLeast, 3.0));
// Alternating fills: the first rows or columns Black 20 %, the next None.
let fills = |a: &designcraft_doc::AltFills| (a.first_color.clone(), a.first_tint, a.next_color.clone(), a.next_tint, a.next);
let want = ("[Black]".to_string(), 0.2f32, "[None]".to_string(), 1.0f32, 1);
assert_eq!(fills(t.options.alt_rows.as_ref().unwrap()), want);
let cols = t.options.alt_cols.as_ref().unwrap();
assert_eq!((fills(cols), cols.first), (want.clone(), 2));
let banded = d.styles.table.iter().find(|s| s.name == "Banded").unwrap();
assert_eq!(fills(banded.alt_rows.as_ref().unwrap()), want);
let c = t.cell(0, 0).unwrap();
assert_eq!((c.insets, c.vj, c.fill.as_str()), ([4.0; 4], VerticalJustification::Top, "[None]"));
for e in &c.strokes {
assert_eq!((e.weight, e.color.as_str(), e.tint, &e.kind), (1.0, "[Black]", 1.0, &designcraft_doc::StrokeType::Solid));
}
}
#[test]
fn imports_hand_written_fixture() {
let d = import_idml_with(&fixture(), &|_| None).unwrap();
@@ -857,3 +1241,132 @@ fn arabic_story_and_table_directions_survive_idml_export() {
assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft);
assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft);
}
#[test]
fn drop_caps_import_and_round_trip() {
// A style with a drop cap, its character style and a nested style through it; a paragraph
// overriding it locally.
let opener = r#"<ParagraphStyle Self="ParagraphStyle/Opener" Name="Opener" DropCapCharacters="1" DropCapLines="3">
<Properties><DropCapStyle type="object">CharacterStyle/Strong</DropCapStyle><AllNestedStyles type="list"><ListItem type="record">
<AppliedCharacterStyle type="object">CharacterStyle/Strong</AppliedCharacterStyle><Delimiter type="enumeration">Dropcap</Delimiter>
<Repetition type="long">1</Repetition><Inclusive type="boolean">true</Inclusive></ListItem></AllNestedStyles></Properties>
</ParagraphStyle>
</RootParagraphStyleGroup>"#;
let styles = STYLES.replace("</RootParagraphStyleGroup>", opener);
let story = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1">
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener" DropCapCharacters="2" DropCapLines="2" DropCapStyle="CharacterStyle/$ID/[No character style]">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Once upon a time</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),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", story),
]);
let check = |d: &Document| {
let st = d.styles.para("Opener").unwrap();
assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines), (Some(1), Some(3)));
assert_eq!(st.para.drop_cap_style.as_deref(), Some("Strong"));
let ns = st.para.nested_styles.clone().unwrap();
assert_eq!((ns[0].style.as_str(), &ns[0].until), ("Strong", &designcraft_doc::NestedUntil::Dropcap));
let p = &d.stories.values().find(|s| s.text.starts_with("Once")).unwrap().paras[0];
assert_eq!((p.para.drop_cap_chars, p.para.drop_cap_lines), (Some(2), Some(2)));
assert_eq!(p.para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE));
};
let d = import_idml_with(&bytes, &|_| None).unwrap();
check(&d);
// The drop cap's character style isn't written to IDML (InDesign sets it through a Dropcap
// nested style); Align Left Edge and Scale for Descenders are, as DropcapDetail.
let mut d = d;
if let Some(s) = d.styles_mut().para_mut("Opener") {
s.para.drop_cap_align_left = Some(false);
s.para.drop_cap_scale_descenders = Some(true);
}
let bytes = export_idml(&d);
assert!(!zip_text(&bytes, "").contains("DropCapStyle"));
assert!(zip_text(&bytes, "Resources/Styles.xml").contains(r#"DropcapDetail="2""#));
let back = import_idml(&bytes).unwrap();
let st = back.styles.para("Opener").unwrap();
assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines, st.para.drop_cap_style.as_deref()), (Some(1), Some(3), None));
assert_eq!((st.para.drop_cap_align_left, st.para.drop_cap_scale_descenders), (Some(false), Some(true)));
assert_eq!(st.para.nested_styles.as_ref().map(|n| n[0].until.clone()), Some(designcraft_doc::NestedUntil::Dropcap));
}
/// Frames take the corners and Text Frame Options they don't write from their object style chain,
/// then `[None]`. Per-corner attributes win over the legacy all-corners pair, at each level; a
/// corner shape without a radius takes the chain's radius.
#[test]
fn frames_inherit_object_style_corners_and_text_frame_options() {
use designcraft_doc::{AutoSize, FirstBaseline, VerticalJustification};
use designcraft_geom::corners::CornerShape as C;
// Anchors in the order top-left, bottom-left, bottom-right, top-right.
let path = |x: f64| {
let pts: String = [(x, 0.0), (x, 50.0), (x + 50.0, 50.0), (x + 50.0, 0.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties>"#
)
};
let corners = |opt: &str, r: &str| {
["TopLeft", "TopRight", "BottomLeft", "BottomRight"].map(|n| format!(r#"{n}CornerOption="{opt}" {n}CornerRadius="{r}""#)).join(" ")
};
let inset = |v: f64| {
format!(
r#"<Properties><InsetSpacing type="list">{}</InsetSpacing></Properties>"#,
r#"<ListItem type="unit">V</ListItem>"#.replace('V', &v.to_string()).repeat(4)
)
};
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="20.2" Self="d">
<RootObjectStyleGroup Self="ro">
<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]" {none}>
<TextFramePreference TextColumnCount="1" TextColumnGutter="12" AutoSizingReferencePoint="CenterPoint" AutoSizingType="Off"/>
</ObjectStyle>
<ObjectStyle Self="ObjectStyle/Rounded" Name="Rounded" TopLeftCornerOption="RoundedCorner" TopRightCornerOption="RoundedCorner" BottomLeftCornerOption="RoundedCorner" BottomRightCornerOption="RoundedCorner" TopLeftCornerRadius="6">
<Properties><BasedOn type="object">ObjectStyle/$ID/[None]</BasedOn></Properties>
<TextFramePreference TextColumnCount="2" TextColumnGutter="18" VerticalJustification="CenterAlign" FirstBaselineOffset="LeadingOffset" MinimumFirstBaselineOffset="4" IgnoreWrap="true" AutoSizingType="HeightOnly">{inset}</TextFramePreference>
</ObjectStyle>
<ObjectStyle Self="ObjectStyle/Child" Name="Child" TopRightCornerOption="BevelCorner">
<Properties><BasedOn type="object">ObjectStyle/Rounded</BasedOn></Properties>
</ObjectStyle>
</RootObjectStyleGroup>
<Story Self="s1"/>
<Spread Self="sp1"><Page Self="p1"/>
<TextFrame Self="tf" ParentStory="s1" AppliedObjectStyle="ObjectStyle/Child" BottomRightCornerOption="InverseRoundedCorner" BottomRightCornerRadius="11.34">{p0}<TextFramePreference TextColumnCount="3"/></TextFrame>
<Rectangle Self="legacy" AppliedObjectStyle="ObjectStyle/Rounded" CornerOption="BevelCorner" CornerRadius="5" TopLeftCornerOption="InsetCorner">{p1}</Rectangle>
<Rectangle Self="one" TopRightCornerOption="RoundedCorner" TopRightCornerRadius="11.34">{p2}</Rectangle>
<Rectangle Self="plain" AppliedObjectStyle="ObjectStyle/Rounded">{p3}</Rectangle>
</Spread>
</Document>"#,
none = corners("None", "12"),
inset = inset(3.0),
p0 = path(0.0),
p1 = path(100.0),
p2 = path(200.0),
p3 = path(300.0),
);
let d = import_idml_with(&zip_files(&[("designmap.xml", &designmap)]), &|_| None).unwrap();
let item = |n: usize| d.spreads[0].items[n].clone();
// Path order: top-left, bottom-left, bottom-right, top-right.
let shapes = |n: usize| item(n).corners.corners.map(|c| (c.shape, c.size));
assert_eq!(shapes(0), [(C::Rounded, 6.0), (C::Rounded, 12.0), (C::InverseRounded, 11.34), (C::Bevel, 12.0)]);
assert_eq!(shapes(1), [(C::Inset, 5.0), (C::Bevel, 5.0), (C::Bevel, 5.0), (C::Bevel, 5.0)]);
assert!(item(2).corners.corners.iter().enumerate().all(|(i, c)| (c.shape == C::Rounded) == (i == 3)));
assert_eq!(item(2).corners.corners[3].size, 11.34);
assert_eq!(shapes(3), [(C::Rounded, 6.0), (C::Rounded, 12.0), (C::Rounded, 12.0), (C::Rounded, 12.0)]);
let tf = item(0);
let o = &tf.text_frame().unwrap().options;
assert_eq!((o.columns, o.gutter, o.inset), (3, 18.0, [3.0; 4]));
assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Center, FirstBaseline::Leading, 4.0));
assert_eq!((o.ignore_wrap, o.auto_size, o.auto_size_ref), (true, AutoSize::HeightOnly, 4));
}
+2 -1
View File
@@ -123,7 +123,8 @@ impl Headless {
let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?;
let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?;
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base);
self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods }, VIEW).map_err(|e| e.to_string())?;
let clicks = e.get("clicks").and_then(Value::as_u64).map_or(1, |n| n.clamp(1, 255) as u8);
self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks }, VIEW).map_err(|e| e.to_string())?;
}
let requests: Vec<Value> = self.session.ui_requests.drain(..).map(|r| serde_json::to_value(r).unwrap_or_default()).collect();
Ok(json!({"selection": self.selection(), "tool": self.session.tool_id(), "requests": requests}))
+1
View File
@@ -277,6 +277,7 @@ pub fn tool_definitions() -> Vec<Value> {
"y": num("y (pt)"),
"space": {"type": "string", "enum": ["doc", "screen"], "description": "Coordinate space (default doc)"},
"mods": mods_schema(),
"clicks": {"type": "integer", "minimum": 1, "description": "On a down: presses in a row it completes (2 double, 3 triple…; default 1)"},
}), &["kind", "x", "y"]),
},
"mods": mods_schema(),
+12
View File
@@ -202,6 +202,18 @@ fn tables_export_cell_text() {
}
}
#[test]
fn drop_cap_text_extracts_in_order() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let para = designcraft_doc::ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..Default::default() };
let text = "Drop caps read in order with the rest of the paragraph, which runs on for a few lines beside the enlarged letters.";
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 236.0, 300.0), lid, text, ParaFormat { para, ..Default::default() }).unwrap();
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let flat: String = extract_text(&bytes)[0].split_whitespace().collect::<Vec<_>>().join(" ");
assert!(flat.starts_with("Drop caps read in order"), "{flat}");
}
/// A 4×2 CMYK TIFF: the left half 100% K (text in a scan), the right half C0 M100 Y100 K0.
fn cmyk_tiff() -> (Vec<u8>, Vec<u8>) {
let (k, red) = ([0u8, 0, 0, 255], [0u8, 255, 255, 0]);
+31
View File
@@ -1238,6 +1238,37 @@ mod tests {
assert!(r.stats.glyphs >= 15);
}
/// An inside-aligned stroke is the stroke of the path inset by half its weight: a band from the
/// edge inwards, its rounded corners concentric with the frame's.
#[test]
fn inside_strokes_are_inset_by_half_the_weight() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = designcraft_doc::ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)));
it.stroke = designcraft_doc::Stroke {
swatch: designcraft_color::swatch::BLACK.into(),
weight: 10.0,
align: designcraft_doc::StrokeAlign::Inside,
..Default::default()
};
it.corners = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 20.0);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
let ink = |x: u32, y: u32| img.pixel(x, y)[0] < 64;
let paper = |x: u32, y: u32| img.pixel(x, y)[0] > 192;
// Straight edges: ink from the edge to 10 pt inside, nothing outside.
assert!(paper(97, 200) && ink(101, 200) && ink(108, 200) && paper(112, 200));
assert!(ink(298, 200) && paper(302, 200) && paper(288, 200));
// Rounded corner centred at (120, 120): ink between radii 10 and 20, paper outside the arc
// and inside the band.
assert!(paper(103, 103) && ink(108, 108) && paper(114, 114));
}
#[test]
fn arrowheads_draw_at_line_ends() {
let mut d = Document::new(&NewDocument::default());
+3 -1
View File
@@ -174,7 +174,9 @@ fn line_box(l: &Line) -> Option<Rect> {
let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min);
let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max);
let pad = l.ascent.max(1.0);
Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, l.baseline + l.descent.max(0.0) + pad))
// A drop cap reaches down past its line.
let bottom = l.drop_cap.map_or(l.baseline + l.descent.max(0.0), |dc| dc.rect.y1.max(l.baseline + l.descent.max(0.0)));
Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, bottom + pad))
}
impl Renderer {
+42 -34
View File
@@ -50,6 +50,46 @@ impl FrameTool {
}
}
/// The rectangle a frame-drawing drag from `sa` to `end` (spread space) makes, snapped when
/// snapping is on, and the snap guides to draw. Shift constrains the shape first; only the
/// driving edge snaps, then the other follows. Alt draws from the centre.
pub(crate) fn snapped_drag_rect(cx: &ToolContext, sr: SpreadRef, sa: Point, end: Point, mods: Mods) -> (Rect, Vec<crate::Overlay>) {
let r = drag_rect(sa, end, mods);
if !cx.snap.any() {
return (r, vec![]);
}
let (mut x_edges, mut y_edges) = moving_edges(sa, end, r, mods.alt);
let driver_x = (end.x - sa.x).abs() >= (end.y - sa.y).abs();
if mods.shift {
if driver_x {
y_edges = [false, false, false];
} else {
x_edges = [false, false, false];
}
}
let hit = crate::snap::snap(
cx,
SnapRequest {
spread: sr,
gesture: Gesture::Create,
rect: r,
x_edges,
y_edges,
exclude: &[],
copying: false,
lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())],
angle: None,
radius: 0.0,
pointer: end,
},
);
let mut r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta);
if mods.shift {
r = restore_shift_frame(sa, end, r, driver_x, mods.alt);
}
(r, hit.guides)
}
pub fn drag_rect(a: Point, b: Point, m: Mods) -> Rect {
let (mut dx, mut dy) = (b.x - a.x, b.y - a.y);
if m.shift {
@@ -365,41 +405,9 @@ impl Tool for FrameTool {
}
out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]})));
} else {
// Shift constrains the shape first. Only the driving edge snaps, then the other follows.
let end = cx.layout.to_spread(sr, ev.pos);
let mut r = drag_rect(sa, end, ev.mods);
if cx.snap.any() {
let (mut x_edges, mut y_edges) = moving_edges(sa, end, r, ev.mods.alt);
let driver_x = (end.x - sa.x).abs() >= (end.y - sa.y).abs();
if ev.mods.shift {
if driver_x {
y_edges = [false, false, false];
} else {
x_edges = [false, false, false];
}
}
let hit = crate::snap::snap(
cx,
SnapRequest {
spread: sr,
gesture: Gesture::Create,
rect: r,
x_edges,
y_edges,
exclude: &[],
copying: false,
lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())],
angle: None,
radius: 0.0,
pointer: end,
},
);
r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta);
if ev.mods.shift {
r = restore_shift_frame(sa, end, r, driver_x, ev.mods.alt);
}
self.guides = hit.guides;
}
let (r, guides) = snapped_drag_rect(cx, sr, sa, end, ev.mods);
self.guides = guides;
self.last = Some((spread_json(sr), r));
out.push(self.preview(spread_json(sr), r));
}
+18 -1
View File
@@ -20,6 +20,7 @@ mod pencil;
pub mod select;
pub mod snap;
mod text;
pub mod thread;
mod xform;
use designcraft_compose::Cache;
@@ -63,16 +64,28 @@ pub struct PointerEvent {
pub pos: Point,
#[serde(default)]
pub mods: Mods,
/// On a press: how many presses in quick succession at this spot it completes (2 = the
/// second of a double click, 3 = a triple click…). 0 counts as 1.
#[serde(default)]
pub clicks: u8,
}
impl PointerEvent {
pub fn new(kind: PointerKind, x: f64, y: f64) -> Self {
Self { kind, pos: Point::new(x, y), mods: Mods::default() }
Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 1 }
}
pub fn with_mods(mut self, m: Mods) -> Self {
self.mods = m;
self
}
pub fn with_clicks(mut self, n: u8) -> Self {
self.clicks = n;
self
}
/// Presses in a row: at least 2 for [`PointerKind::DoubleClick`], at least 1 otherwise.
pub fn click_count(&self) -> u8 {
if self.kind == PointerKind::DoubleClick { self.clicks.max(2) } else { self.clicks.max(1) }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -342,6 +355,10 @@ pub enum Cursor {
ZoomOut,
Eyedropper,
LoadedText,
/// Loaded text over a frame a click threads to.
ThreadLink,
/// Loaded text over the frame a click unthreads from.
Unthread,
LoadedGraphic,
NotAllowed,
}
+90 -16
View File
@@ -3,11 +3,13 @@
//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains),
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool.
//! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]).
use designcraft_doc::{ItemId, SpreadRef};
use designcraft_geom::{Affine, Point, Rect, Vec2};
use serde_json::{Value, json};
use crate::thread::Loaded;
use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext, ToolKey, rect_json, spread_json};
#[derive(Clone, Debug)]
@@ -38,6 +40,11 @@ enum Drag {
start: Point,
cur: Point,
},
/// A press with the loaded text cursor.
Place {
start: Point,
cur: Point,
},
Anchor {
id: u64,
si: usize,
@@ -100,11 +107,58 @@ pub struct SelectionTool {
guides: Vec<Overlay>,
/// Selected item to toggle if this Shift press never becomes a drag.
shift_release: Option<u64>,
/// The loaded text cursor.
loaded: Option<Loaded>,
}
impl SelectionTool {
pub fn new(direct: bool) -> Self {
Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None }
Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, loaded: None }
}
/// Ports and the loaded text cursor. None: not a threading event.
fn thread_pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
let p = ev.pos;
let exec = |c: Option<(&'static str, Value)>| c.map(|(id, params)| Action::Exec(id.into(), params)).into_iter().collect::<Vec<_>>();
// A double click: sent as such, or the canvas's second press.
if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick)
&& ev.click_count() == 2
&& let Some((frame, out)) = crate::thread::port_at(cx, p)
{
self.loaded = None;
self.drag = Drag::None;
return Some(exec(crate::thread::unthread_at_port(cx.doc, frame, out)));
}
self.loaded = self.loaded.and_then(|l| l.resolve(cx.doc));
match (ev.kind, self.loaded) {
(PointerKind::Down | PointerKind::DoubleClick, _) if let Some((frame, out)) = crate::thread::port_at(cx, p) => {
self.loaded = Some(Loaded { frame, out, advance: false });
self.drag = Drag::None;
Some(vec![])
}
(PointerKind::Down | PointerKind::DoubleClick, Some(_)) => {
self.drag = Drag::Place { start: p, cur: p };
Some(vec![])
}
(PointerKind::Drag, Some(_)) => {
if let Drag::Place { start, .. } = self.drag {
self.drag = Drag::Place { start, cur: p };
}
Some(vec![])
}
(PointerKind::Up, Some(l)) => {
let Drag::Place { start, .. } = std::mem::replace(&mut self.drag, Drag::None) else { return Some(vec![]) };
let cmd = l.click(cx, start, p, ev.mods);
// Alt (semi-autoflow) stays loaded with what is left; an invalid target keeps the load.
if cmd.is_some() {
self.loaded = (ev.mods.alt && !ev.mods.shift && cmd.as_ref().is_some_and(|c| c.0 == "text.thread"))
.then_some(Loaded { advance: true, ..l });
}
Some(exec(cmd))
}
(PointerKind::Move, Some(_)) => Some(vec![]),
_ => None,
}
}
}
@@ -371,6 +425,19 @@ impl Tool for SelectionTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
if !self.direct
&& let Some(actions) = self.thread_pointer(cx, ev)
{
return actions;
}
// The second press of a double click on a text frame: edit its text.
if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick)
&& ev.click_count() == 2
&& let Some(actions) = edit_text_at(cx, p)
{
self.drag = Drag::None;
return actions;
}
match ev.kind {
PointerKind::Move => {
self.hover_handle = handle_at(cx, p);
@@ -592,6 +659,7 @@ impl Tool for SelectionTool {
self.drag = Drag::Marquee { start, cur: p };
vec![]
}
Drag::Place { .. } => vec![],
Drag::Rotate { center, start_angle, start_rotation } => {
let mut a = ((p - center).atan2() - start_angle).to_degrees();
self.guides.clear();
@@ -703,26 +771,19 @@ impl Tool for SelectionTool {
}
vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))]
}
Drag::None => vec![],
Drag::None | Drag::Place { .. } => vec![],
}
}
PointerKind::DoubleClick => {
if let Some((_, id)) = cx.hit(p)
&& cx.doc.item(id).is_some_and(|i| i.is_text_frame())
{
let (sr, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p));
let _ = sr;
return vec![
Action::SwitchTool("type".into()),
Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]})),
];
}
vec![]
}
PointerKind::DoubleClick => vec![],
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.loaded.take().is_some() {
// Unload; the selection stays. Cancel (no interaction runs) marks the key handled.
self.drag = Drag::None;
return vec![Action::Cancel];
}
let inc = cx.doc.settings.keyboard_increment * if mods.shift { 10.0 } else { 1.0 };
let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))];
if cx.selection.items.is_empty() {
@@ -741,7 +802,7 @@ impl Tool for SelectionTool {
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match &self.drag {
Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
Drag::Marquee { start, cur } | Drag::Place { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),
_ => vec![],
}
@@ -751,6 +812,9 @@ impl Tool for SelectionTool {
if self.direct {
return Cursor::ArrowHollow;
}
if let Some(l) = self.loaded.and_then(|l| l.resolve(cx.doc)) {
return if crate::thread::port_at(cx, p).is_some() { Cursor::LoadedText } else { l.cursor(cx, p) };
}
match self.drag {
Drag::Move { .. } => return Cursor::Move,
Drag::Resize { handle, .. } => return handle_cursor(handle),
@@ -777,3 +841,13 @@ fn handle_cursor(h: usize) -> Cursor {
_ => Cursor::ResizeH,
}
}
/// Switch to the Type tool with the caret at `p` when it is over a text frame.
fn edit_text_at(cx: &ToolContext, p: Point) -> Option<Vec<Action>> {
let (_, id) = cx.hit(p)?;
if !cx.doc.item(id).is_some_and(|i| i.is_text_frame()) {
return None;
}
let (_, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p));
Some(vec![Action::SwitchTool("type".into()), Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))])
}
+32 -3
View File
@@ -1,6 +1,8 @@
//! Snapping and smart guides.
//!
//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone.
//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone; the
//! baseline grid and the guides (ruler guides, margins, columns) count as one pass where the
//! nearer target wins.
//! Ruler guides, margins, and columns draw nothing. Alignment draws [`Overlay::Guide`] in
//! canvas coordinates. Equal spacing draws [`Overlay::Gap`] the same way, through one
//! spread-to-canvas transform. A rotation match draws [`Overlay::Measure`] at the pointer
@@ -374,13 +376,22 @@ fn format_angle(deg: f64) -> String {
fn first_on_axis(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
pass_grid(cx, req, axis, tol)
.or_else(|| pass_baseline(cx, req, axis, tol))
.or_else(|| pass_guides(cx, req, axis, tol))
.or_else(|| nearer(pass_baseline(cx, req, axis, tol), pass_guides(cx, req, axis, tol)))
.or_else(|| pass_align(cx, req, axis, tol))
.or_else(|| pass_spacing(cx, req, axis, tol))
.or_else(|| pass_dimensions(cx, req, axis, tol))
}
/// The baseline grid and the guides (ruler guides, margins, columns) compete by distance, so a
/// margin is reachable between the lines of a fine baseline grid at any zoom. A tie goes to the
/// guide.
fn nearer(baseline: Option<AxisHit>, guide: Option<AxisHit>) -> Option<AxisHit> {
match (baseline, guide) {
(Some(b), Some(g)) => Some(if b.delta.abs() < g.delta.abs() { b } else { g }),
(b, g) => g.or(b),
}
}
/// Document grid. No overlay.
///
/// The interval is the major spacing divided by the subdivision count. A count below 1 counts
@@ -1747,6 +1758,24 @@ mod tests {
assert_eq!(hit.delta.y, 0.0);
}
#[test]
fn nearer_of_margin_and_baseline_wins() {
// Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744.
let doc = Document::new(&NewDocument {
margins: designcraft_doc::Margins { top: 54.0, bottom: 54.0, inside: 54.0, outside: 42.0 },
..Default::default()
});
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
// At 50% the 4 px zone is 8 pt: both the margin and a baseline line are in reach.
let cx = ToolContext { zoom: 0.5, ..ctx_on(&doc, &sel, &cache, &layout) };
let bottom = |y1: f64| SnapRequest { y_edges: [false, false, true], gesture: Gesture::Resize, ..y_req(Rect::new(100.0, 600.0, 140.0, y1)) };
assert!((snap(&cx, bottom(739.5)).delta.y - -1.5).abs() < 1e-6, "the margin is nearer");
assert!((snap(&cx, bottom(742.5)).delta.y - 1.5).abs() < 1e-6, "the baseline line is nearer");
assert!((snap(&cx, bottom(741.0)).delta.y - -3.0).abs() < 1e-6, "a tie goes to the margin");
}
#[test]
fn rotated_item_matches_its_own_side() {
let mut doc = Document::new(&NewDocument::default());
+234
View File
@@ -705,3 +705,237 @@ fn pen_click_snaps_to_a_guide() {
let x = p["anchors"][0]["p"][0].as_f64().unwrap();
assert!((x - 100.0).abs() < 1e-6, "anchor x {x}");
}
/// A text frame with overset text (selected), an empty frame and a threaded pair x → y, on page 1.
struct ThreadDoc {
d: Document,
l: CanvasLayout,
s: Selection,
c: Cache,
/// The selected text frame.
a: ItemId,
/// An empty (unassigned) frame.
e: ItemId,
x: ItemId,
y: ItemId,
}
fn thread_doc() -> ThreadDoc {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
let lid = d.default_layer();
let (a, _) = d
.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 250.0, 160.0), lid, &"Words flow on. ".repeat(40), ParaFormat::default())
.unwrap();
let e = ItemId(d.alloc());
d.insert_item(SpreadRef::Doc(0), Item::new(e, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 400.0, 250.0, 500.0))), None).unwrap();
let (x, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 100.0, 450.0, 160.0), lid, "x", ParaFormat::default()).unwrap();
let (y, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 400.0, 450.0, 500.0), lid, "", ParaFormat::default()).unwrap();
d.thread(x, y).unwrap();
let l = CanvasLayout::new(&d, false);
ThreadDoc { s: Selection::items(vec![a]), c: Cache::new(), l, d, a, e, x, y }
}
impl ThreadDoc {
fn cx(&self) -> ToolContext<'_> {
ctx(&self.d, &self.s, &self.c, &self.l)
}
/// Canvas point of a spread-0 point.
fn at(&self, x: f64, y: f64) -> Point {
self.l.to_canvas(SpreadRef::Doc(0), Point::new(x, y))
}
fn port(&self, id: ItemId, out: bool) -> Point {
thread::port_center(&self.d, &self.l, id, out, 1.0).unwrap()
}
}
fn click(t: &mut Box<dyn Tool>, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
let mut v = t.pointer(cx, &PointerEvent::new(PointerKind::Down, p.x, p.y).with_mods(mods));
v.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Up, p.x, p.y).with_mods(mods)));
v
}
fn thread_exec(p: serde_json::Value) -> Vec<Action> {
vec![Action::Exec("text.thread".into(), p)]
}
#[test]
fn out_port_loads_text_and_a_click_threads_an_empty_frame() {
let td = thread_doc();
let cx = td.cx();
let mut t = create("selection");
let port = td.port(td.a, true);
// The out port sits on the right edge, 12 px above the bottom-right corner.
assert!((port - td.at(250.0, 148.0)).hypot() < 1e-9, "{port:?}");
// A press just off the port's centre still loads (any zoom: tolerance is in pixels).
assert!(click(&mut t, &cx, port + designcraft_geom::Vec2::new(3.0, -3.0), Mods::default()).is_empty());
assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText);
let over_e = td.at(175.0, 450.0);
assert_eq!(t.cursor(&cx, over_e, Mods::default()), Cursor::ThreadLink);
// A frame something already threads into can't join; neither can the loaded frame itself.
assert_eq!(t.cursor(&cx, td.at(375.0, 450.0), Mods::default()), Cursor::NotAllowed);
assert_eq!(t.cursor(&cx, td.at(175.0, 130.0), Mods::default()), Cursor::LoadedText);
assert_eq!(click(&mut t, &cx, over_e, Mods::default()), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0})));
// Unloaded again: the cursor and clicks are back to normal.
assert_eq!(t.cursor(&cx, over_e, Mods::default()), Cursor::Arrow);
}
#[test]
fn ports_hit_at_any_zoom() {
let td = thread_doc();
let mut cx = td.cx();
cx.zoom = 4.0;
let port = thread::port_center(&td.d, &td.l, td.a, false, 4.0).unwrap();
assert!((port - td.at(100.0, 100.0 + 14.5 / 4.0)).hypot() < 1e-9);
assert_eq!(thread::port_at(&cx, port + designcraft_geom::Vec2::new(1.5, 1.5)), Some((td.a, false)));
assert_eq!(thread::port_at(&cx, port + designcraft_geom::Vec2::new(4.0, 0.0)), None, "8 screen px away at 4×");
}
#[test]
fn loaded_click_on_empty_page_makes_a_column_frame() {
let td = thread_doc();
let cx = td.cx();
let mut t = create("selection");
click(&mut t, &cx, td.port(td.a, true), Mods::default());
let col = td.d.spreads[0].pages[0].column_rects()[0];
let a = click(&mut t, &cx, td.at(200.0, 600.0), Mods::default());
assert_eq!(a, thread_exec(serde_json::json!({"from": td.a.0, "rect": [col.x0, 600.0, col.x1, col.y1], "spread": {"kind": "doc", "index": 0}})));
}
#[test]
fn loaded_drag_makes_a_frame_of_that_size_and_shift_autoflows() {
let td = thread_doc();
let cx = td.cx();
let mut t = create("selection");
click(&mut t, &cx, td.port(td.a, true), Mods::default());
let (p0, p1) = (td.at(320.0, 560.0), td.at(470.0, 700.0));
let shift = Mods { shift: true, ..Default::default() };
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p0.x, p0.y).with_mods(shift)).is_empty());
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p1.x, p1.y).with_mods(shift)).is_empty());
assert_eq!(t.overlays(&cx), vec![Overlay::Marquee(Rect::from_points(p0, p1))]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p1.x, p1.y).with_mods(shift));
assert_eq!(
a,
thread_exec(
serde_json::json!({"from": td.a.0, "rect": [320.0, 560.0, 470.0, 700.0], "spread": {"kind": "doc", "index": 0}, "autoflow": true})
)
);
assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow, "autoflow unloads");
}
#[test]
fn alt_click_stays_loaded() {
let td = thread_doc();
let cx = td.cx();
let mut t = create("selection");
click(&mut t, &cx, td.port(td.a, true), Mods::default());
let alt = Mods { alt: true, ..Default::default() };
assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0})));
assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText);
}
#[test]
fn escape_unloads_and_keeps_the_selection() {
let td = thread_doc();
let cx = td.cx();
let mut t = create("selection");
click(&mut t, &cx, td.port(td.a, true), Mods::default());
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow);
// An ordinary click on empty space deselects again.
let p = td.at(500.0, 700.0);
assert_eq!(
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)),
vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": []}))]
);
}
#[test]
fn loaded_click_on_the_linked_frame_unthreads() {
// x is selected; its out port links to y.
let td = thread_doc();
let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td };
let cx = td.cx();
let mut t = create("selection");
click(&mut t, &cx, td.port(td.x, true), Mods::default());
let over_y = td.at(375.0, 450.0);
assert_eq!(t.cursor(&cx, over_y, Mods::default()), Cursor::Unthread);
assert_eq!(
click(&mut t, &cx, over_y, Mods::default()),
vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.x.0, "side": "after"}))]
);
// From y's in port, x is the frame before.
let td = ThreadDoc { s: Selection::items(vec![td.y]), ..td };
let cx = td.cx();
click(&mut t, &cx, td.port(td.y, false), Mods::default());
assert_eq!(
click(&mut t, &cx, td.at(375.0, 130.0), Mods::default()),
vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.y.0, "side": "before"}))]
);
}
#[test]
fn in_port_threads_in_front() {
let td = thread_doc();
let cx = td.cx();
let mut t = create("selection");
click(&mut t, &cx, td.port(td.a, false), Mods::default());
assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), Mods::default()), thread_exec(serde_json::json!({"from": td.e.0, "to": td.a.0})));
}
#[test]
fn double_click_on_a_port_breaks_its_link() {
let td = thread_doc();
let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td };
let cx = td.cx();
// A double click sent as such (control channel), and the canvas's second press.
let second = |p: Point| [PointerEvent::new(PointerKind::DoubleClick, p.x, p.y), PointerEvent::new(PointerKind::Down, p.x, p.y).with_clicks(2)];
let p = td.port(td.x, true);
for ev in second(p) {
let mut t = create("selection");
click(&mut t, &cx, p, Mods::default());
let a = t.pointer(&cx, &ev);
assert_eq!(a, vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.x.0, "side": "after"}))], "{ev:?}");
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p.x, p.y)).is_empty());
assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow);
// The in port of the first frame has no link: nothing to break.
let q = td.port(td.x, false);
for ev in second(q) {
assert!(t.pointer(&cx, &ev).is_empty(), "{ev:?}");
}
}
}
#[test]
fn clicks_in_a_row_edit_and_select_text() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 200.0), lid, "Two words", ParaFormat::default()).unwrap();
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
let cx = ctx(&d, &s, &c, &l);
let off = l.xf(SpreadRef::Doc(0)).translation();
let (x, y) = (150.0 + off.x, 110.0 + off.y);
let press = |n: u8| PointerEvent::new(PointerKind::Down, x, y).with_clicks(n);
// Selection tool: the second press of a double click edits the text there.
let mut t = create("selection");
let a = t.pointer(&cx, &press(1));
assert!(matches!(&a[0], Action::Exec(id, _) if id == "selection.set"), "{a:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y));
let a = t.pointer(&cx, &press(2));
assert_eq!(a.first(), Some(&Action::SwitchTool("type".into())));
assert!(matches!(&a[1], Action::Exec(id, p) if id == "text.placeCaret" && p["frame"] == fid.0), "{a:?}");
// Type tool: word, line, paragraph, then the story.
let mut t = create("type");
let unit = |a: Vec<Action>| match a.as_slice() {
[Action::Exec(id, p)] if id == "text.selectAt" => p["unit"].as_str().map(str::to_string),
[Action::Exec(id, _)] if id == "text.placeCaret" => None,
other => panic!("{other:?}"),
};
assert_eq!(unit(t.pointer(&cx, &press(1))), None);
for (n, want) in [(2, "word"), (3, "line"), (4, "paragraph"), (5, "story"), (9, "story")] {
assert_eq!(unit(t.pointer(&cx, &press(n))).as_deref(), Some(want), "{n} clicks");
}
// A double click sent as such (control channel) selects a word too.
assert_eq!(unit(t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y))).as_deref(), Some("word"));
// Moving a little while pressed keeps the unit selected.
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 2.0, y)).is_empty());
}
+31 -11
View File
@@ -5,7 +5,7 @@
use designcraft_geom::Point;
use serde_json::json;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::drag_rect, rect_json, spread_json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::snapped_drag_rect, rect_json, spread_json};
#[derive(Default)]
pub struct TypeTool {
@@ -17,6 +17,10 @@ pub struct TypeTool {
/// A press on a table whose whole rows or columns are selected: (frame, press point in
/// spread space) — a drag moves them, a click places the caret.
cell_drag: Option<(u64, Point)>,
/// Snap guides while drawing a frame.
guides: Vec<Overlay>,
/// The press selected a word, line, paragraph or story (several clicks): drags keep it.
unit_selected: bool,
}
/// Are whole rows or whole columns of a table selected?
@@ -45,31 +49,39 @@ impl Tool for TypeTool {
self.drawing = false;
self.selecting = None;
self.cell_drag = None;
self.unit_selected = false;
if let Some((_, id)) = cx.hit(ev.pos)
&& let Some(it) = cx.doc.item(id)
&& !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. })
{
let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos);
let clicks = ev.click_count();
// Whole rows/columns selected: wait to see whether this is a drag.
if ev.kind == PointerKind::Down && !ev.mods.shift && whole_rows_or_cols(cx) {
if clicks == 1 && !ev.mods.shift && whole_rows_or_cols(cx) {
self.cell_drag = Some((id.0, sp));
return vec![];
}
self.selecting = Some(id.0);
let cmd = if ev.kind == PointerKind::DoubleClick {
"text.selectWord"
} else if ev.mods.shift {
"text.extendTo"
} else {
"text.placeCaret"
};
// Two presses select a word (or a run of spaces), three a line, four a
// paragraph, five the story.
if clicks >= 2 {
self.unit_selected = true;
let unit = match clicks {
2 => "word",
3 => "line",
4 => "paragraph",
_ => "story",
};
return vec![Action::Exec("text.selectAt".into(), json!({"frame": id.0, "point": [sp.x, sp.y], "unit": unit}))];
}
let cmd = if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" };
return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))];
}
vec![]
}
PointerKind::Drag => {
let Some(a) = self.start else { return vec![] };
if self.cell_drag.is_some() {
if self.cell_drag.is_some() || self.unit_selected {
return vec![];
}
if let Some(fid) = self.selecting {
@@ -80,7 +92,10 @@ impl Tool for TypeTool {
return vec![];
}
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
let r = drag_rect(sa, cx.layout.to_spread(sr, ev.pos), ev.mods);
// Drawn like the frame tools: the press point and the moving edges snap.
let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa };
let (r, guides) = snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, ev.pos), ev.mods);
self.guides = guides;
let mut out = vec![];
if !self.drawing {
self.drawing = true;
@@ -93,6 +108,7 @@ impl Tool for TypeTool {
out
}
PointerKind::Up => {
self.guides.clear();
let start = self.start.take();
if let Some((fid, from)) = self.cell_drag.take() {
let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos);
@@ -145,6 +161,10 @@ impl Tool for TypeTool {
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
if self.drawing { self.guides.clone() } else { vec![] }
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
match cx.hit(p) {
Some((_, id)) if cx.doc.item(id).is_some_and(|i| i.is_text_frame()) => Cursor::Text,
+199
View File
@@ -0,0 +1,199 @@
//! Text threads with the Selection tool: the in and out ports of selected text frames, and the
//! loaded text cursor a click on a port gives.
//!
//! Loaded from an out port, a click threads the clicked frame after the port's frame; loaded
//! from an in port, in front of it. A click on empty space makes a frame the width of the column
//! there, a drag makes one of the dragged size. Clicking the frame on the other side of an
//! existing link breaks the thread there. Shift autoflows, Alt stays loaded with the rest.
use designcraft_doc::{Document, Item, ItemId, SpreadRef};
use designcraft_geom::{Point, Rect};
use serde_json::{Value, json};
use crate::{CanvasLayout, Cursor, Mods, ToolContext, rect_json, spread_json};
/// Port square size, in screen pixels.
pub const PORT_SIZE_PX: f64 = 8.5;
/// The in port sits this far below the top-left corner, the out port this far above the
/// bottom-right corner (screen pixels along the frame edge).
const IN_PORT_PX: f64 = 14.5;
const OUT_PORT_PX: f64 = 12.0;
/// Screen pixels around a port that still hit it.
const PORT_SLOP_PX: f64 = 2.0;
/// A click frame shorter than this (the click was at or below the bottom margin) gets
/// [`CLICK_FRAME_HEIGHT`] instead.
const MIN_CLICK_FRAME_HEIGHT: f64 = 12.0;
const CLICK_FRAME_HEIGHT: f64 = 72.0;
/// Canvas centre of a text frame's in port (on its left edge below the top-left corner) or out
/// port (on its right edge above the bottom-right corner), at `zoom` screen pixels per point.
pub fn port_center(doc: &Document, layout: &CanvasLayout, id: ItemId, out: bool, zoom: f64) -> Option<Point> {
let it = doc.item(id)?;
let loc = doc.find(id)?;
let m = layout.xf(loc.spread) * doc.parent_xf(&loc) * it.xf;
let r = it.inner_bounds();
let (corner, toward) = if out { (Point::new(r.x1, r.y1), Point::new(r.x1, r.y0)) } else { (Point::new(r.x0, r.y0), Point::new(r.x0, r.y1)) };
let c = m * corner;
let edge = (m * toward) - c;
let len = edge.hypot();
let step = (if out { OUT_PORT_PX } else { IN_PORT_PX }) / zoom.max(1e-9);
if !(len.is_finite() && len > 1e-9 && step.is_finite()) {
return Some(c);
}
// Along the edge, never past its middle on a tiny frame.
Some(c + edge * (step.min(len / 2.0) / len))
}
/// The port of a selected text frame under `p` (canvas): (frame, out port?).
pub fn port_at(cx: &ToolContext, p: Point) -> Option<(ItemId, bool)> {
let half = cx.tol(PORT_SIZE_PX / 2.0 + PORT_SLOP_PX);
for id in &cx.selection.items {
if !cx.doc.item(*id).is_some_and(Item::is_text_frame) {
continue;
}
for out in [true, false] {
if let Some(c) = port_center(cx.doc, cx.layout, *id, out, cx.zoom)
&& (c.x - p.x).abs() <= half
&& (c.y - p.y).abs() <= half
{
return Some((*id, out));
}
}
}
None
}
/// The frame a click with the loaded cursor makes on empty space (spread coordinates): the width
/// of the column under the click (the nearest column of the nearest page), from the click down
/// to the bottom margin. Off the page's columns it starts at the click, a column wide.
pub fn click_frame_rect(doc: &Document, sr: SpreadRef, p: Point) -> Option<Rect> {
let sp = doc.spread(sr)?;
let page = sp.pages.get(sp.page_at_x(p.x)?)?;
let cols = page.column_rects();
let dist = |c: &Rect| {
if p.x < c.x0 {
c.x0 - p.x
} else if p.x > c.x1 {
p.x - c.x1
} else {
0.0
}
};
let col = cols.iter().min_by(|a, b| dist(a).total_cmp(&dist(b)))?;
let on_page = p.x >= page.x && p.x <= page.x + page.width;
let (x0, x1) = if on_page { (col.x0, col.x1) } else { (p.x, p.x + col.width()) };
let y1 = if col.y1 - p.y >= MIN_CLICK_FRAME_HEIGHT { col.y1 } else { p.y + CLICK_FRAME_HEIGHT };
let r = Rect::new(x0, p.y, x1.max(x0 + 1.0), y1);
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r)
}
/// The loaded text cursor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Loaded {
/// The frame whose port was clicked.
pub frame: ItemId,
/// Loaded from the out port (thread after `frame`) or the in port (in front of it).
pub out: bool,
/// Semi-autoflow: the frame just threaded on that side takes over once the document has it.
pub advance: bool,
}
/// What a click with the loaded cursor does at a point.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Target {
/// Nothing there: a new frame.
Empty,
/// A frame that can join the thread.
Thread(ItemId),
/// The frame on the other side of the port's link: break the thread there.
Unthread,
/// A frame that can't join (the loaded frame itself, a picture, a frame text flows into…).
Invalid,
}
impl Loaded {
/// The loaded frame now (an advancing load moves on to the frame threaded last), or None
/// when that frame is gone (undo, delete).
pub fn resolve(self, doc: &Document) -> Option<Loaded> {
let frame = if self.advance { self.neighbour(doc).unwrap_or(self.frame) } else { self.frame };
doc.item(frame).filter(|i| i.is_text_frame()).map(|_| Loaded { frame, out: self.out, advance: false })
}
/// The next frame from an out port, the previous one from an in port.
fn neighbour(self, doc: &Document) -> Option<ItemId> {
if self.out { doc.next_frame(self.frame) } else { doc.prev_frame(self.frame) }
}
/// `text.thread` `from`/`to` for joining frame `t` (None: a new frame) on this side.
fn pair(self, doc: &Document, t: Option<ItemId>) -> (Option<ItemId>, Option<ItemId>) {
match (self.out, doc.prev_frame(self.frame)) {
(true, _) => (Some(self.frame), t),
(false, Some(prev)) => (Some(prev), t),
(false, None) => (t, Some(self.frame)),
}
}
pub fn target(self, cx: &ToolContext, p: Point) -> Target {
let Some((_, id)) = cx.hit(p) else { return Target::Empty };
if Some(id) == self.neighbour(cx.doc) {
return Target::Unthread;
}
match self.pair(cx.doc, Some(id)) {
(Some(from), Some(to)) if cx.doc.check_thread(from, to).is_ok() => Target::Thread(id),
_ => Target::Invalid,
}
}
pub fn cursor(self, cx: &ToolContext, p: Point) -> Cursor {
match self.target(cx, p) {
Target::Empty => Cursor::LoadedText,
Target::Thread(_) => Cursor::ThreadLink,
Target::Unthread => Cursor::Unthread,
Target::Invalid if cx.hit(p).is_some_and(|(_, id)| id == self.frame) => Cursor::LoadedText,
Target::Invalid => Cursor::NotAllowed,
}
}
/// The command for a click at `p`, or a drag from `start` to `p` (canvas). None: nothing to do.
pub fn click(self, cx: &ToolContext, start: Point, p: Point, mods: Mods) -> Option<(&'static str, Value)> {
let dragged = (p - start).hypot() >= cx.tol(3.0);
let (pair, new_rect) = if dragged {
let (sr, a) = cx.layout.spread_at(start)?;
(self.pair(cx.doc, None), Some((sr, Rect::from_points(a, cx.layout.to_spread(sr, p)))))
} else {
match self.target(cx, p) {
Target::Unthread => {
let side = if self.out { "after" } else { "before" };
return Some(("text.unthread", json!({"frame": self.frame.0, "side": side})));
}
Target::Invalid => return None,
Target::Thread(t) => (self.pair(cx.doc, Some(t)), None),
Target::Empty => {
let (sr, sp) = cx.layout.spread_at(p)?;
(self.pair(cx.doc, None), Some((sr, click_frame_rect(cx.doc, sr, sp)?)))
}
}
};
let mut params = serde_json::Map::new();
if let Some(f) = pair.0 {
params.insert("from".into(), json!(f.0));
}
if let Some(t) = pair.1 {
params.insert("to".into(), json!(t.0));
}
if let Some((sr, r)) = new_rect {
params.insert("rect".into(), rect_json(r));
params.insert("spread".into(), spread_json(sr));
}
if mods.shift {
params.insert("autoflow".into(), json!(true));
}
Some(("text.thread", Value::Object(params)))
}
}
/// Double-click on a port: break the link it shows, if it has one.
pub(crate) fn unthread_at_port(doc: &Document, frame: ItemId, out: bool) -> Option<(&'static str, Value)> {
let linked = if out { doc.next_frame(frame).is_some() } else { doc.prev_frame(frame).is_some() };
linked.then(|| ("text.unthread", json!({"frame": frame.0, "side": if out { "after" } else { "before" }})))
}
+77 -28
View File
@@ -337,6 +337,36 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info())
};
ui.ctx().set_cursor_icon(cursor_icon(c));
draw_cursor_badge(&painter, p, c, &t);
}
}
/// The loaded text cursor's badge beside the system cursor: lines of text, a closed chain
/// (a click threads) or a broken one (a click unthreads). Drawn in code.
fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) {
if !matches!(c, Cursor::LoadedText | Cursor::ThreadLink | Cursor::Unthread) {
return;
}
let r = Rect::from_min_size(p + vec2(12.0, 12.0), vec2(20.0, 16.0));
painter.rect_filled(r, 3.0, t.measure_bg);
let ink = Stroke::new(1.5, t.measure_text);
let c0 = r.center();
match c {
Cursor::LoadedText => {
for (dy, w) in [(-4.0, 12.0), (0.0, 12.0), (4.0, 8.0)] {
painter.line_segment([pos2(c0.x - 6.0, c0.y + dy), pos2(c0.x - 6.0 + w, c0.y + dy)], ink);
}
}
_ => {
let gap = if c == Cursor::Unthread { 2.5 } else { -2.0 };
for side in [-1.0, 1.0] {
let link = Rect::from_center_size(pos2(c0.x + side * (3.5 + gap / 2.0), c0.y), vec2(9.0, 6.0));
painter.rect_stroke(link, 3.0, ink, StrokeKind::Middle);
}
if c == Cursor::Unthread {
painter.line_segment([pos2(c0.x + 2.0, c0.y - 6.0), pos2(c0.x - 2.0, c0.y + 6.0)], ink);
}
}
}
}
@@ -359,6 +389,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::ZoomOut => C::ZoomOut,
Cursor::Eyedropper => C::Crosshair,
Cursor::LoadedText | Cursor::LoadedGraphic => C::Copy,
Cursor::ThreadLink => C::Alias,
Cursor::Unthread => C::NoDrop,
Cursor::NotAllowed => C::NotAllowed,
}
}
@@ -808,7 +840,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
union = Some(union.map_or(b, |u| u.union(b)));
// Text frame ports.
if let Content::Text(tf) = &it.content {
draw_ports(app, painter, xf, doc, layout, it, tf.story, a, color);
draw_ports(app, painter, xf, doc, layout, it, tf.story, color);
}
if let Content::Graphic(g) = &it.content
&& sel.content
@@ -901,7 +933,6 @@ fn draw_ports(
layout: &CanvasLayout,
it: &Item,
sid: designcraft_doc::StoryId,
a: Affine,
color: Color32,
) {
let Some(story) = doc.story(sid) else { return };
@@ -909,14 +940,11 @@ fn draw_ports(
let is_last = pos + 1 == story.frames.len();
let cs = app.session.cache.get(doc, sid, None);
let overset = is_last && cs.is_overset();
let s = 8.5;
let _ = layout;
let r = it.inner_bounds();
let m = a * it.xf;
let s = designcraft_tools::thread::PORT_SIZE_PX as f32;
// In port on the left edge below the top-left corner; out port on the right edge above the
// bottom-right corner (screen-space offsets).
let inp = xf.to_screen(m * Point::new(r.x0, r.y0)) + vec2(0.0, 14.5);
let outp = xf.to_screen(m * Point::new(r.x1, r.y1)) - vec2(0.0, 12.0);
// bottom-right corner. The Selection tool hit-tests the same places.
let port = |out: bool| designcraft_tools::thread::port_center(doc, layout, it.id, out, xf.zoom).map(|c| xf.to_screen(c));
let (Some(inp), Some(outp)) = (port(false), port(true)) else { return };
for (c, has_link, is_out) in [(inp, pos > 0, false), (outp, !is_last, true)] {
let pr = Rect::from_center_size(c, vec2(s, s));
painter.rect_filled(pr, 0.0, Color32::WHITE);
@@ -1011,6 +1039,15 @@ fn draw_text_selection(
let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { compose::caret_x(l, e) };
quads.push(quad(x0, x1));
}
// A selected drop cap is highlighted down to its baseline.
if let Some(dc) = l.drop_cap {
let sel = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < dc.end && g.byte >= s && g.byte < e);
let (a, b) = sel.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), g| (a.min(g.x), b.max(g.x + g.adv)));
if a < b {
let r = dc.rect;
quads.push([Point::new(a, r.y0), Point::new(b, r.y0), Point::new(b, r.y1), Point::new(a, r.y1)]);
}
}
for (gi, g) in l.glyphs.iter().enumerate() {
if g.visible && g.len > 0 && g.byte >= s && g.byte < e {
glyphs.push((li, gi));
@@ -1372,20 +1409,21 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
let mut events: Vec<PointerEvent> = Vec::new();
let down_id = egui::Id::new(("canvas_pointer_down", app.pane));
let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false);
let (pressed, released, origin, latest, dbl) = ui.input(|i| {
(
i.pointer.primary_pressed(),
i.pointer.primary_released(),
i.pointer.press_origin(),
i.pointer.latest_pos(),
i.pointer.button_double_clicked(egui::PointerButton::Primary),
)
});
let clicks_id = egui::Id::new(("canvas_clicks", app.pane));
let (pressed, released, origin, latest, now) =
ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos(), i.time));
if pressed
&& !space
&& let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered())
{
events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m });
// Count presses in a row here: egui reports double and triple clicks only on release and
// stops at three, but a press must know (word, line, paragraph, story).
// Like egui, the next press must come within the double-click delay of the last release.
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
let (released_at, last_pos, last_n): (f64, Pos2, u8) = ui.data(|d| d.get_temp(clicks_id)).unwrap_or((f64::NEG_INFINITY, o, 0));
let n = if now - released_at <= delay && (o - last_pos).length() <= dist { last_n.saturating_add(1) } else { 1 };
ui.data_mut(|d| d.insert_temp(clicks_id, (f64::NEG_INFINITY, o, n)));
events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n });
down = true;
}
if down
@@ -1393,7 +1431,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
&& resp.drag_delta() != egui::Vec2::ZERO
&& let Some(p) = latest
{
events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m });
events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 });
}
// Power Zoom: holding the Hand tool still for half a second turns the press into one.
let hold_id = egui::Id::new(("canvas_hold", app.pane));
@@ -1403,8 +1441,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
let still = origin_pos.zip(latest).is_some_and(|(o, l)| (o - l).length() < 3.0);
if !fired && still && now - start >= 0.5 {
let p = pos(latest.unwrap_or(rect.center()));
events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m });
events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m } });
events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m, clicks: 1 });
events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m }, clicks: 1 });
ui.data_mut(|d| d.insert_temp(hold_id, (start, true)));
} else {
ui.data_mut(|d| d.insert_temp(hold_id, (start, fired)));
@@ -1416,11 +1454,14 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
ui.data_mut(|d| d.remove::<(f64, bool)>(hold_id));
}
if down && released {
if let Some((_, at, n)) = ui.data(|d| d.get_temp::<(f64, Pos2, u8)>(clicks_id)) {
ui.data_mut(|d| d.insert_temp(clicks_id, (now, at, n)));
}
let p = latest.unwrap_or(rect.center());
events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m });
events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 1 });
down = false;
} else if !down && let Some(p) = resp.hover_pos() {
events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m });
events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m, clicks: 1 });
}
ui.data_mut(|d| d.insert_temp(down_id, down));
for e in events {
@@ -1450,11 +1491,19 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
let _ = app.run(id, json!({"text": t}));
}
egui::Event::Copy | egui::Event::Cut if wants_text => {
// `run` puts the copied text on the system clipboard.
let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" };
if let Ok(r) = app.run(id, json!({}))
&& let Some(t) = r.get("text").and_then(serde_json::Value::as_str)
{
ui.ctx().copy_text(t.to_string());
let _ = app.run(id, json!({}));
}
// Without a native menu bar the copy and paste keys arrive only as these events.
egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) if !app.native_menu => {
let id = match e {
egui::Event::Copy => "edit.copy",
egui::Event::Cut => "edit.cut",
_ => "edit.paste",
};
if crate::menus::enabled(app, id) {
crate::menus::activate(app, id, &serde_json::Value::Null);
}
}
egui::Event::Key { key, pressed: true, modifiers, .. } => {
+55 -2
View File
@@ -99,7 +99,15 @@ fn key_from(name: &str) -> Option<egui::Key> {
})
}
/// Handle one control request. Commands it runs leave the user's system clipboard alone.
pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let was = std::mem::replace(&mut app.in_control, true);
let out = handle_request(app, ctx, req);
app.in_control = was;
out
}
fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
match req.method.as_str() {
@@ -126,6 +134,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
let view = app.view_info();
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
let mut last = None;
for e in events {
let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") {
"down" => PointerKind::Down,
@@ -146,13 +155,19 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
designcraft_geom::Point::new(x, y)
};
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods }, view) {
let clicks = e.get("clicks").and_then(Value::as_u64).map_or(1, |n| n.clamp(1, 255) as u8);
if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods, clicks }, view) {
return err(e);
}
app.after_engine();
last = Some((pos, mods));
}
ctx.request_repaint();
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()})))
// The tool's cursor where the last event was.
let cursor = last.map(|(pos, mods)| app.session.cursor(pos, mods, view));
wrap(
app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id(), "cursor": cursor})),
)
}
"ui.key" => {
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
@@ -361,3 +376,41 @@ pub fn save_screenshot(app: &mut DesignApp, image: &egui::ColorImage, path: Opti
None => json!({"ok": false, "error": "no writer configured"}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
#[test]
fn control_paste_never_reads_the_system_clipboard() {
let calls = Arc::new(AtomicUsize::new(0));
let seen = calls.clone();
let services = crate::Services {
clipboard_text: Some(Box::new(move || {
seen.fetch_add(1, Ordering::SeqCst);
Some("SECRET".into())
})),
..Default::default()
};
let mut app = DesignApp::new(designcraft_engine::Session::new(), services);
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "words"})).unwrap();
let sid = r["story"].as_u64().unwrap();
assert!(app.session.active().unwrap().selection.text.is_some(), "the new frame is being typed in");
let text = |app: &DesignApp| app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone();
let ctx = egui::Context::default();
let (req, _rx) = ControlRequest::new("engine.execute", json!({"command": "edit.paste"}));
let _ = handle(&mut app, &ctx, &req);
assert_eq!(calls.load(Ordering::SeqCst), 0, "the control channel must not read the system clipboard");
assert!(!text(&app).contains("SECRET"));
assert!(!app.in_control);
// The same command chosen by the user does paste the system clipboard.
let _ = app.run("edit.paste", json!({}));
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(text(&app).contains("SECRET"));
}
}
+49 -4
View File
@@ -1484,7 +1484,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
"colorPicker" => {
let hex = d.s("hex");
app.run("object.color", json!({"color": hex, "target": d.s("target")}))
app.run("object.color", json!({"color": hex, "target": d.s("target"), "text": d.b("text")}))
}
"layerOptions" => {
let hidden: Vec<Value> = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default();
@@ -1778,6 +1778,41 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
});
}
"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;
@@ -1821,9 +1856,19 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &kind)))
.width(100.0)
.show_ui(ui, |ui| {
for k in
["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "chars"]
{
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;
+23 -6
View File
@@ -1691,13 +1691,13 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
(
"Select an object to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
[
"Select an object to color its fill or stroke.",
"Select an object to color its fill or stroke.",
"Select an object to color its fill or stroke.",
"Select an object to color its fill or stroke.",
"选择一个对象以设置其填色或描边颜色。",
"Select an object or text to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
"选择一个对象或文本以设置其填色或描边颜色。",
],
),
(
@@ -1902,6 +1902,23 @@ const TABLE: &[(&str, [&str; 5])] = &[
("forcedLineBreak", ["forcedLineBreak", "forcedLineBreak", "forcedLineBreak", "forcedLineBreak", "强制换行符"]),
("emSpace", ["emSpace", "emSpace", "emSpace", "emSpace", "全角空格"]),
("enSpace", ["enSpace", "enSpace", "enSpace", "enSpace", "半角空格"]),
("dropcap", ["Initiale", "lettrine", "capitular", "ドロップキャップ", "首字下沉"]),
("Drop Caps", ["Initialen", "Lettrines", "Capitulares", "ドロップキャップ", "首字下沉"]),
("Lines:", ["Zeilen:", "Lignes :", "Líneas:", "行数:", "行数:"]),
("Characters:", ["Zeichen:", "Caractères :", "Caracteres:", "文字数:", "字符数:"]),
("Drop Lines", ["Initialzeilen", "Lignes lettrine", "Líneas capitular", "ドロップ行数", "下沉行数"]),
("Drop Chars", ["Initialzeichen", "Car. lettrine", "Car. capitular", "ドロップ文字数", "下沉字符数"]),
("Align Left Edge", ["Linke Kante ausrichten", "Aligner le bord gauche", "Alinear borde izquierdo", "左端揃え", "左边缘对齐"]),
(
"Scale for Descenders",
[
"Für Unterlängen skalieren",
"Mise à l'échelle pour les jambages",
"Escalar para trazos descendentes",
"ディセンダーに合わせて拡大・縮小",
"为下伸部分缩放",
],
),
("chars", ["chars", "chars", "chars", "chars", "指定字符"]),
("[Automatic]", ["[Automatic]", "[Automatic]", "[Automatic]", "[Automatic]", "[自动]"]),
("Tracking:", ["Tracking:", "Tracking:", "Tracking:", "Tracking:", "字距调整:"]),
+9 -1
View File
@@ -318,6 +318,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("forcedLineBreak", "فاصل سطر إجباري"),
("emSpace", "مسافة em"),
("enSpace", "مسافة en"),
("dropcap", "الحرف الاستهلالي"),
("Drop Caps", "الأحرف الاستهلالية"),
("Lines:", "الأسطر:"),
("Characters:", "الأحرف:"),
("Drop Lines", "أسطر الاستهلال"),
("Drop Chars", "أحرف الاستهلال"),
("Align Left Edge", "محاذاة الحافة اليسرى"),
("Scale for Descenders", "تحجيم للأحرف الهابطة"),
("chars", "محارف محددة"),
("[Automatic]", "[تلقائي]"),
("Size:", "الحجم:"),
@@ -915,7 +923,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
"Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).",
"حدد فيديو أو صوتًا مدرجًا (ملف › إدراج ملف .mp4 أو .mov أو .mp3 أو .wav …).",
),
("Select an object to color its fill or stroke.", "حدد كائنًا لتلوين تعبئته أو حده."),
("Select an object or text to color its fill or stroke.", "حدد كائنًا أو نصًا لتلوين تعبئته أو حده."),
("Select an object to give its fill a gradient.", "حدد كائنًا لإضافة تدرج إلى تعبئته."),
("Select an object to make it a button.", "حدد كائنًا لتحويله إلى زر."),
("Select an object.", "حدد كائنًا."),
+45 -2
View File
@@ -40,6 +40,7 @@ pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>;
pub type OpenAsyncFn = Box<dyn FnMut(&str)>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
pub type ClipboardFn = Box<dyn FnMut() -> Option<String>>;
/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files).
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
@@ -61,6 +62,9 @@ pub struct Services {
/// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`,
/// anything else → `file.place`.
pub inbox: Option<Inbox>,
/// The system clipboard's text, for Paste chosen from a menu (egui only delivers it with the
/// paste keys, which a native menu bar takes first).
pub clipboard_text: Option<ClipboardFn>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -187,6 +191,9 @@ pub struct UiState {
pub smart_spacing: bool,
#[serde(default = "default_zone")]
pub snap_zone: f64,
/// Tools panel: Formatting Affects Text (J): with text frames selected, colour edits go to
/// their text instead of the frames.
pub formatting_affects_text: bool,
/// Window > Contextual Task Bar.
pub task_bar: bool,
/// Help › About DesignCraft is open.
@@ -263,6 +270,7 @@ impl Default for UiState {
smart_dimensions: true,
smart_spacing: true,
snap_zone: 4.0,
formatting_affects_text: false,
task_bar: true,
about: false,
about_tab: 0,
@@ -418,6 +426,11 @@ pub struct DesignApp {
last_time: f64,
/// When recovery data was last written (seconds, egui time).
pub last_recovery: f64,
/// The egui context (set by the first frame): copied text goes to the system clipboard through it.
egui_ctx: Option<egui::Context>,
/// A control-channel request is being handled: commands it runs never read or write the
/// user's system clipboard (a control client must not see or replace it).
pub(crate) in_control: bool,
}
impl DesignApp {
@@ -454,6 +467,8 @@ impl DesignApp {
native_shortcuts: Default::default(),
last_time: 0.0,
last_recovery: 0.0,
egui_ctx: None,
in_control: false,
}
}
@@ -497,14 +512,39 @@ impl DesignApp {
if let Some(r) = menus::run_ui(self, id, &params) {
return r;
}
// Only user-initiated runs touch the system clipboard, never the control channel's.
let system_clipboard = !self.in_control;
let params = if system_clipboard { self.with_system_clipboard(id, params) } else { params };
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
self.after_engine();
if let Err(e) = &r {
self.status(e.clone());
match &r {
Err(e) => self.status(e.clone()),
// Copied text goes to the system clipboard too, whichever way Copy was chosen.
Ok(v) if system_clipboard && matches!(id, "edit.copy" | "edit.cut") => {
if let (Some(t), Some(ctx)) = (v.get("text").and_then(Value::as_str), &self.egui_ctx) {
ctx.copy_text(t.to_string());
ctx.request_repaint();
}
}
Ok(_) => {}
}
r
}
/// Paste into text without the clipboard's text (a menu, not the paste keys): read it here.
fn with_system_clipboard(&mut self, id: &str, mut params: Value) -> Value {
let typing = self.session.active().is_some_and(|d| d.selection.text.is_some());
if typing
&& matches!(id, "edit.paste" | "edit.pasteWithoutFormatting")
&& params.get("text").is_none()
&& let Some(text) = self.services.clipboard_text.as_mut().and_then(|f| f())
&& let Some(o) = params.as_object_mut()
{
o.insert("text".into(), Value::String(text));
}
params
}
/// Handle requests produced by tools/commands (dialogs, view changes, file pickers).
pub fn after_engine(&mut self) {
let reqs = std::mem::take(&mut self.session.ui_requests);
@@ -580,6 +620,9 @@ impl DesignApp {
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
if self.egui_ctx.is_none() {
self.egui_ctx = Some(ctx.clone());
}
if !self.color_applied {
self.color_applied = true;
if let Some(cs) = self.ui.color_settings.clone() {
+35
View File
@@ -79,6 +79,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"),
("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"),
("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"),
(
"app.formattingAffectsText",
"Formatting Affects Text",
Some("J"),
"{on?: bool} — with text frames selected, colours go to their text (on) or the frames (off); toggles without `on`",
),
("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"),
("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"),
("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"),
@@ -1229,6 +1235,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
"zone": ui.snap_zone,
}))
}
"app.formattingAffectsText" => {
app.ui.formatting_affects_text = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.formatting_affects_text);
Ok(json!({"on": app.ui.formatting_affects_text}))
}
"window.richBlack" => {
app.ui.rich_black = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.rich_black);
app.canvas.shown = None;
@@ -2018,6 +2028,31 @@ mod tests {
}
}
#[test]
fn formatting_affects_text_sends_swatches_to_the_text() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "words", "caret": false})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
app.run("selection.set", json!({"ids": [fid]})).unwrap();
let text_fill = |app: &crate::DesignApp| {
let st = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
st.char_format_at(2).over.fill.clone()
};
let frame_fill = |app: &crate::DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();
assert!(!crate::panels::colors_affect_text(&app));
crate::panels::apply_swatch(&mut app, false, "Cyan", None).unwrap();
assert_eq!(frame_fill(&app), "Cyan");
assert_eq!(text_fill(&app), None);
let r = run_ui(&mut app, "app.formattingAffectsText", &json!({})).unwrap().unwrap();
assert_eq!(r["on"], true, "toggles on");
assert!(crate::panels::colors_affect_text(&app));
crate::panels::apply_swatch(&mut app, false, "Magenta", Some(0.5)).unwrap();
assert_eq!(frame_fill(&app), "Cyan", "the frame is left alone");
assert_eq!(text_fill(&app).as_deref(), Some("Magenta"));
assert_eq!(crate::panels::color_target(&mut app).map(|t| (t.0, t.1)), Some(("Magenta".to_string(), 0.5)));
}
#[test]
fn transform_menu_items_open_their_dialogs() {
for id in ["transform.move", "transform.scale", "transform.rotate", "transform.shear"] {
+38
View File
@@ -46,6 +46,44 @@ pub struct SelInfo {
pub locked: bool,
}
/// Colour edits go to text: text is selected, or text frames are selected with Formatting
/// Affects Text on.
pub fn colors_affect_text(app: &DesignApp) -> bool {
let Some(st) = app.session.active() else { return false };
st.selection.text.is_some()
|| (app.ui.formatting_affects_text && st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|i| i.is_text_frame())))
}
/// Fill and stroke swatches and tints of what colour edits apply to: the text (see
/// [`colors_affect_text`]) or the first selected object.
pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> {
if colors_affect_text(app) {
let a = text_attrs(app)?;
let c = &a["chars"];
let tint = |k: &str| c[k].as_f64().unwrap_or(1.0) as f32;
return Some((c["fill"].as_str()?.to_string(), tint("fillTint"), c["stroke"].as_str()?.to_string(), tint("strokeTint")));
}
let i = sel_info(app)?;
Some((i.fill, sel_tint(app, false), i.stroke, sel_tint(app, true)))
}
/// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to.
pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option<f32>) -> Result<Value, String> {
if colors_affect_text(app) {
let (k, kt) = if stroke { ("stroke", "strokeTint") } else { ("fill", "fillTint") };
let mut attrs = json!({k: swatch});
if let Some(t) = tint {
attrs[kt] = json!(t);
}
return app.run("type.char", json!({ "attrs": attrs }));
}
let mut p = json!({"swatch": swatch});
if let Some(t) = tint {
p["tint"] = json!(t);
}
app.run(if stroke { "object.stroke" } else { "object.fill" }, p)
}
/// Fill (or stroke) tint of the first selected item (1 = 100%).
pub fn sel_tint(app: &DesignApp, stroke: bool) -> f32 {
let Some(st) = app.session.active() else { return 1.0 };
+40
View File
@@ -1288,6 +1288,35 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
crate::rtl::label(ui, c["fill"].as_str().unwrap_or(""));
});
ui.horizontal(|ui| {
// Bold and Italic pick the family's own styles (style linking).
let (weight, italic) =
designcraft_fonts::FontDb::global().traits_of(c["fontFamily"].as_str().unwrap_or_default(), c["fontStyle"].as_str().unwrap_or_default());
let tip = |app: &DesignApp, name: &str, id: &str| {
let name = crate::i18n::tr(&app.ui.language, name);
match crate::menus::shortcut_of(app, id) {
Some(sc) => format!("{name} ({})", crate::menus::shortcut_text(&sc)),
None => name.to_string(),
}
};
let (bold_tip, italic_tip) = (tip(app, "Bold", "type.bold"), tip(app, "Italic", "type.italic"));
if ui
.selectable_label(weight >= 650.0, egui::RichText::new("B").strong())
.on_hover_ui(|ui| {
crate::rtl::label(ui, bold_tip);
})
.clicked()
{
let _ = app.run("type.bold", json!({}));
}
if ui
.selectable_label(italic, egui::RichText::new("I").italics())
.on_hover_ui(|ui| {
crate::rtl::label(ui, italic_tip);
})
.clicked()
{
let _ = app.run("type.italic", json!({}));
}
let caps = c["capitalization"].as_str() == Some("allCaps");
if ui
.selectable_label(caps, "TT")
@@ -1453,6 +1482,17 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
}
ui.end_row();
}
// 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);
+24 -21
View File
@@ -11,8 +11,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let text = st.selection.text.is_some();
let fill_cur = super::sel_info(app).map(|i| i.fill);
let text = super::colors_affect_text(app);
let fill_cur = super::color_target(app).map(|t| t.0);
ui.horizontal(|ui| {
crate::rtl::label(
ui,
@@ -25,7 +25,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
// colour groups are listed under their folder.
let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect();
for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref(), text);
swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref());
}
for g in &doc.color_groups {
let open_id = egui::Id::new(("color_group_open", &g.name));
@@ -58,7 +58,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
if open {
for n in &g.swatches {
if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) {
swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref(), text);
swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref());
}
}
}
@@ -112,8 +112,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
/// One Swatches panel row: chip, name, type; click applies to fill (or text), the context menu
/// to stroke, colour groups and delete.
/// One Swatches panel row: chip, name, type; click applies to the fill (of the frames or their
/// text, see [`super::colors_affect_text`]), the context menu to the stroke, colour groups and delete.
fn swatch_row(
app: &mut DesignApp,
ui: &mut egui::Ui,
@@ -121,7 +121,6 @@ fn swatch_row(
sw: &designcraft_color::swatch::Swatch,
indent: f32,
fill_cur: Option<&str>,
text: bool,
) {
let t = Tokens::get(ui.ctx());
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
@@ -153,12 +152,11 @@ fn swatch_row(
}
ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim);
if resp.clicked() {
let r = if text { app.run("type.char", json!({"attrs": {"fill": sw.name}})) } else { app.run("object.fill", json!({"swatch": sw.name})) };
let _ = r;
let _ = super::apply_swatch(app, false, &sw.name, None);
}
resp.context_menu(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply to Stroke"))).clicked() {
let _ = app.run("object.stroke", json!({"swatch": sw.name}));
let _ = super::apply_swatch(app, true, &sw.name, None);
ui.close();
}
if !sw.locked {
@@ -199,13 +197,14 @@ pub enum ColorMode {
/// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`).
pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let Some(i) = super::sel_info(app) else {
let Some((fill, fill_tint, stroke_sw, stroke_tint)) = super::color_target(app) else {
ui.label(crate::rtl::widget(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object to color its fill or stroke.")).color(t.text_dim),
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object or text to color its fill or stroke.")).color(t.text_dim),
));
return;
};
let text = super::colors_affect_text(app);
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
let stroke_id = egui::Id::new("color_panel_stroke");
let mode_id = egui::Id::new("color_panel_mode");
@@ -213,11 +212,11 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb };
let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode);
let target = if stroke { "stroke" } else { "fill" };
let name = if stroke { i.stroke.clone() } else { i.fill.clone() };
let name = if stroke { stroke_sw.clone() } else { fill.clone() };
// Proxy: fill (front) and stroke chips; click to choose, double-click for the Color Picker.
ui.horizontal(|ui| {
for (k, label) in [(false, "Fill"), (true, "Stroke")] {
let n = if k { &i.stroke } else { &i.fill };
let n = if k { &stroke_sw } else { &fill };
let (c, g) = crate::widgets::swatch_colors(&doc, n, 1.0);
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
crate::widgets::paint_chip(ui.painter(), r.shrink(2.0), c, g);
@@ -232,7 +231,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let rgb = doc.resolve_color(n, 1.0).map(|c| c.to_rgb()).unwrap_or([0.0; 3]);
let hex =
format!("#{:02x}{:02x}{:02x}", (rgb[0] * 255.0).round() as u8, (rgb[1] * 255.0).round() as u8, (rgb[2] * 255.0).round() as u8);
app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if k { "stroke" } else { "fill" }, "hex": hex})));
app.ui.dialog =
Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if k { "stroke" } else { "fill" }, "hex": hex, "text": text})));
}
}
ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim));
@@ -266,13 +266,12 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
};
// A named swatch shows its tint; mixing its values makes an unnamed colour.
if let Some(sw) = sw.as_ref().filter(|w| !w.hidden && !w.locked) {
let cur = if stroke { super::sel_tint(app, true) } else { super::sel_tint(app, false) };
let cur = if stroke { stroke_tint } else { fill_tint };
let mut tint = cur * 100.0;
ui.horizontal(|ui| {
ui.label("T");
if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).suffix("%").integer()).drag_stopped() {
let cmd = if stroke { "object.stroke" } else { "object.fill" };
let _ = app.run(cmd, json!({"swatch": sw.name, "tint": tint / 100.0}));
let _ = super::apply_swatch(app, stroke, &sw.name, Some(tint / 100.0));
}
});
}
@@ -308,13 +307,13 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
}
}
if let Some(color) = send {
let _ = app.run("object.color", json!({"color": color, "target": target}));
let _ = app.run("object.color", json!({"color": color, "target": target, "text": text}));
}
spectrum(app, ui, target);
ui.horizontal(|ui| {
let unnamed = sw.as_ref().is_some_and(|w| w.hidden);
if ui.add_enabled(unnamed, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add to Swatches")))).clicked() {
let _ = app.run("swatch.addToSwatches", json!({"target": target}));
let _ = app.run("swatch.addToSwatches", json!({"swatch": name, "target": target}));
}
});
}
@@ -384,7 +383,11 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) {
&& let Some(p) = resp.interact_pointer_pos()
{
let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0));
let _ = app.run("object.color", json!({"color": [(c[0] * 255.0).round(), (c[1] * 255.0).round(), (c[2] * 255.0).round()], "target": target}));
let text = super::colors_affect_text(app);
let _ = app.run(
"object.color",
json!({"color": [(c[0] * 255.0).round(), (c[1] * 255.0).round(), (c[2] * 255.0).round()], "target": target, "text": text}),
);
}
}
+13 -13
View File
@@ -179,14 +179,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
/// utility button and the Screen Mode well.
fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) {
let t = Tokens::get(ui.ctx());
let info = crate::panels::sel_info(app);
let target = crate::panels::color_target(app);
let doc = app.session.active().map(|d| d.doc.clone());
let stroke_front_id = egui::Id::new("proxy_stroke_front");
let text_id = egui::Id::new("proxy_text");
let stroke_front: bool = ui.data(|d| d.get_temp(stroke_front_id)).unwrap_or(false);
let text_mode: bool = ui.data(|d| d.get_temp(text_id)).unwrap_or(false);
let (fill_sw, stroke_sw) = match &info {
Some(i) => (i.fill.clone(), i.stroke.clone()),
let text_mode = crate::panels::colors_affect_text(app) || app.ui.formatting_affects_text;
let (fill_sw, stroke_sw) = match &target {
Some((fill, _, stroke, _)) => (fill.clone(), stroke.clone()),
None => ("[None]".to_string(), "[Black]".to_string()),
};
let x0 = ui.max_rect().min.x + (width - 30.0) / 2.0;
@@ -201,8 +200,10 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) {
})
.clicked()
{
let _ = app.run("object.fill", json!({"swatch": "[None]"}));
let _ = app.run("object.stroke", json!({"swatch": "[Black]"}));
// Text defaults to a black fill and no stroke; objects to no fill and a black stroke.
let (fill, stroke) = if crate::panels::colors_affect_text(app) { ("[Black]", "[None]") } else { ("[None]", "[Black]") };
let _ = crate::panels::apply_swatch(app, false, fill, None);
let _ = crate::panels::apply_swatch(app, true, stroke, None);
}
let swap = egui::Rect::from_min_size(egui::pos2(x0 + 19.0, row.min.y), vec2(11.0, 11.0));
icons::paint(ui.painter(), swap, "swap", t.icon);
@@ -213,8 +214,8 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) {
})
.clicked()
{
let _ = app.run("object.fill", json!({"swatch": stroke_sw}));
let _ = app.run("object.stroke", json!({"swatch": fill_sw}));
let _ = crate::panels::apply_swatch(app, false, &stroke_sw, None);
let _ = crate::panels::apply_swatch(app, true, &fill_sw, None);
}
ui.add_space(2.0);
// Proxy: 19.5 pt squares, fill top-left, stroke bottom-right.
@@ -285,7 +286,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) {
})
.clicked()
{
ui.data_mut(|d| d.insert_temp(text_id, k == 1));
let _ = app.run("app.formattingAffectsText", json!({"on": k == 1}));
}
}
ui.add_space(5.0);
@@ -299,14 +300,13 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) {
let chip = egui::Rect::from_center_size(well.center(), vec2(14.0, 14.0));
crate::widgets::paint_chip(ui.painter(), chip, None, None);
flyout_triangle(ui.painter(), well, t.icon);
let target = if stroke_front { "object.stroke" } else { "object.fill" };
if resp.clicked() {
let _ = app.run(target, json!({"swatch": "[None]"}));
let _ = crate::panels::apply_swatch(app, stroke_front, "[None]", None);
}
egui::Popup::context_menu(&resp).show(|ui| {
for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
let _ = app.run(target, json!({"swatch": sw}));
let _ = crate::panels::apply_swatch(app, stroke_front, sw, None);
ui.close();
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser
| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf |
| `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups |
| `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) |
| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse |
| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) |
| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel |
| `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state |
+3 -2
View File
@@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
<!-- SUMMARY -->
**Weighted breadth parity: 99%** over 275 features.
**Weighted breadth parity: 99%** over 276 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 100 | 2 | 0 | 99% |
| P1 | 97 | 97 | 0 | 0 | 100% |
| P1 | 98 | 98 | 0 | 0 | 100% |
| P2 | 76 | 72 | 4 | 0 | 97% |
| Area | Parity |
@@ -224,6 +224,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
| Typography | Single-line Composer | P0 | D |
| Typography | Tabs, leaders, right-indent tab | P0 | D |
| Typography | Nested / line / GREP styles | P1 | D |
| Typography | Drop caps | P1 | D |
| Typography | Paragraph rules | P1 | D |
| Typography | Paragraph borders & shading | P1 | D |
| Typography | Balance ragged lines | P1 | D |