mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M7: per-paragraph paragraph attributes
Each paragraph has its own alignment, indents, spacing, hyphenation, tab stops and paragraph style (TextObject.paras; para stays paragraph 0's, so old files and readers work). Layout, editing (split/merge), threads, the Paragraph/Tabs/Paragraph Styles panels, ranged text.setStyle/setFormat/ tabs/paraStyle.apply, SVG export/import and the fuzzers follow.
This commit is contained in:
1 parent
6def232852
commit
f7761bf5bc
30 files changed
+991
-169
No files matched your search
+3
-3
@@ -139,7 +139,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, Italian and Russian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
@@ -176,7 +176,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
@@ -211,7 +211,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 |
|
||||
|
||||
@@ -812,6 +812,11 @@ fn reverse_from_end(path: &mut PathData) {
|
||||
}
|
||||
|
||||
/// A text object. `runs` split into paragraphs at `\n`.
|
||||
///
|
||||
/// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every
|
||||
/// paragraph's). Invariant (kept by [`TextObject::normalize_paras`]): `paras` is either empty —
|
||||
/// every paragraph uses `para` — or holds one style per paragraph with `paras[0] == para` and at
|
||||
/// least two different styles. Readers that only know `para` see the first paragraph's style.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextObject {
|
||||
/// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents.
|
||||
@@ -823,6 +828,9 @@ pub struct TextObject {
|
||||
pub runs: Vec<TextRun>,
|
||||
#[serde(default)]
|
||||
pub para: ParaStyle,
|
||||
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub paras: Vec<ParaStyle>,
|
||||
/// Area Type Options (area type only).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub area: AreaOptions,
|
||||
@@ -857,6 +865,7 @@ impl TextObject {
|
||||
xf: Affine::translate(origin.to_vec2()),
|
||||
runs: vec![TextRun { text: text.into(), style }],
|
||||
para: ParaStyle::default(),
|
||||
paras: Vec::new(),
|
||||
area: AreaOptions::default(),
|
||||
path_effect: PathEffect::default(),
|
||||
path_align: PathAlign::default(),
|
||||
@@ -869,6 +878,103 @@ impl TextObject {
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
/// Number of paragraphs (`\n`-separated; empty text has one).
|
||||
pub fn paragraph_count(&self) -> usize {
|
||||
1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>()
|
||||
}
|
||||
/// Attributes of paragraph `i` (the last paragraph's past the end).
|
||||
pub fn para_at(&self, i: usize) -> &ParaStyle {
|
||||
self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para)
|
||||
}
|
||||
/// Every paragraph's attributes, one per paragraph.
|
||||
pub fn paragraph_styles(&self) -> Vec<ParaStyle> {
|
||||
(0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect()
|
||||
}
|
||||
/// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its
|
||||
/// last entry, a long one is cut).
|
||||
pub fn set_paragraph_styles(&mut self, styles: Vec<ParaStyle>) {
|
||||
if let Some(first) = styles.first() {
|
||||
self.para = first.clone();
|
||||
}
|
||||
self.paras = styles;
|
||||
self.normalize_paras();
|
||||
}
|
||||
/// The same attributes for every paragraph.
|
||||
pub fn set_all_paras(&mut self, style: ParaStyle) {
|
||||
self.para = style;
|
||||
self.paras.clear();
|
||||
}
|
||||
/// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the
|
||||
/// last), `paras[0] == para`, and empty when every paragraph is alike.
|
||||
pub fn normalize_paras(&mut self) {
|
||||
if self.paras.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = self.paragraph_count();
|
||||
let last = self.paras.last().cloned().unwrap_or_default();
|
||||
self.paras.resize(n, last);
|
||||
if let Some(first) = self.paras.first_mut() {
|
||||
first.clone_from(&self.para);
|
||||
}
|
||||
if self.paras.iter().all(|p| *p == self.para) {
|
||||
self.paras.clear();
|
||||
}
|
||||
}
|
||||
/// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret
|
||||
/// touches its paragraph).
|
||||
pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range<usize> {
|
||||
let text = self.plain_text();
|
||||
let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len()));
|
||||
let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count());
|
||||
index(a)..index(b) + 1
|
||||
}
|
||||
/// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph).
|
||||
pub fn edit_paras(&mut self, range: Option<std::ops::Range<usize>>, mut f: impl FnMut(&mut ParaStyle)) {
|
||||
match range {
|
||||
None => {
|
||||
f(&mut self.para);
|
||||
for p in &mut self.paras {
|
||||
f(p);
|
||||
}
|
||||
}
|
||||
Some(r) => {
|
||||
let mut v = self.paragraph_styles();
|
||||
for p in v.iter_mut().take(r.end).skip(r.start) {
|
||||
f(p);
|
||||
}
|
||||
self.set_paragraph_styles(v);
|
||||
}
|
||||
}
|
||||
self.normalize_paras();
|
||||
}
|
||||
/// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames.
|
||||
pub fn para_styles_mut(&mut self) -> impl Iterator<Item = &mut ParaStyle> {
|
||||
std::iter::once(&mut self.para).chain(self.paras.iter_mut())
|
||||
}
|
||||
/// Every stored paragraph style (`para` and the per-paragraph ones).
|
||||
pub fn para_styles(&self) -> impl Iterator<Item = &ParaStyle> {
|
||||
std::iter::once(&self.para).chain(self.paras.iter())
|
||||
}
|
||||
/// Update the paragraph styles for replacing bytes `start..end` of the current plain text by
|
||||
/// `insert` (call before changing the runs). The paragraph the range starts in keeps its
|
||||
/// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that
|
||||
/// style too (Return continues the paragraph it splits); later paragraphs keep theirs.
|
||||
pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) {
|
||||
if self.paras.is_empty() {
|
||||
return;
|
||||
}
|
||||
let r = self.paragraphs_in(start, end);
|
||||
let v = self.paragraph_styles();
|
||||
let Some(keep) = v.get(r.start).cloned() else { return };
|
||||
let added = insert.bytes().filter(|&b| b == b'\n').count();
|
||||
let mut out: Vec<ParaStyle> = v.iter().take(r.start + 1).cloned().collect();
|
||||
out.extend(std::iter::repeat_n(keep, added));
|
||||
out.extend(v.iter().skip(r.end).cloned());
|
||||
self.paras = out;
|
||||
if let Some(first) = self.paras.first() {
|
||||
self.para = first.clone();
|
||||
}
|
||||
}
|
||||
pub fn first_style(&self) -> CharStyle {
|
||||
self.runs.first().map(|r| r.style.clone()).unwrap_or_default()
|
||||
}
|
||||
@@ -996,6 +1102,56 @@ mod tests {
|
||||
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
|
||||
}
|
||||
|
||||
fn justified(j: Justify) -> ParaStyle {
|
||||
ParaStyle { justify: j, ..ParaStyle::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_styles_split_merge_and_normalize() {
|
||||
let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default());
|
||||
assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored");
|
||||
assert_eq!(t.paragraphs_in(0, 0), 0..1);
|
||||
assert_eq!(t.paragraphs_in(4, 4), 1..2);
|
||||
assert_eq!(t.paragraphs_in(2, 9), 0..3);
|
||||
assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text");
|
||||
t.edit_paras(Some(1..2), |p| p.justify = Justify::Center);
|
||||
let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::<Vec<_>>();
|
||||
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]);
|
||||
// Return inside "two": the new paragraph continues its style.
|
||||
t.splice_paras(5, 5, "\n");
|
||||
t.runs[0].text.insert(5, '\n');
|
||||
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]);
|
||||
// Deleting the break between "one" and "t": the first paragraph's style wins.
|
||||
t.splice_paras(3, 4, "");
|
||||
t.runs[0].text.remove(3);
|
||||
assert_eq!(t.plain_text(), "onet\nwo\nthree");
|
||||
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]);
|
||||
// An edit that skips the splice is repaired by normalizing (the last style continues).
|
||||
t.runs[0].text.push_str("\nfour");
|
||||
t.normalize_paras();
|
||||
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]);
|
||||
// `para` is paragraph 0's; all alike collapses to `para` alone.
|
||||
t.edit_paras(Some(0..1), |p| p.justify = Justify::Right);
|
||||
assert_eq!(t.para.justify, Justify::Right);
|
||||
t.edit_paras(None, |p| p.justify = Justify::Right);
|
||||
assert!(t.paras.is_empty() && t.para.justify == Justify::Right);
|
||||
// A short list repeats its last entry, a long one is cut.
|
||||
t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]);
|
||||
assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]);
|
||||
t.set_paragraph_styles(vec![justified(Justify::Center); 9]);
|
||||
assert!(t.paras.is_empty() && t.para.justify == Justify::Center);
|
||||
// Old documents (no `paras`) load; new ones keep paragraph 0 in `para`.
|
||||
t.edit_paras(Some(3..4), |p| p.space_before = 4.0);
|
||||
let json = serde_json::to_value(&t).unwrap();
|
||||
assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]);
|
||||
let mut old = json.clone();
|
||||
old.as_object_mut().unwrap().remove("paras");
|
||||
let back: TextObject = serde_json::from_value(old).unwrap();
|
||||
assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center);
|
||||
let back: TextObject = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.para_at(3).space_before, 4.0);
|
||||
}
|
||||
|
||||
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
|
||||
fn area_type() -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());
|
||||
|
||||
@@ -803,9 +803,10 @@ impl Picked {
|
||||
run_attrs(&mut r.style, &placed, ap);
|
||||
}
|
||||
if let Some((_, para)) = text.filter(|_| a.paragraph) {
|
||||
t.para = para.clone();
|
||||
// The source's first paragraph's attributes, on every paragraph.
|
||||
t.set_all_paras(para.clone());
|
||||
}
|
||||
if text.is_some() && a.character {
|
||||
if text.is_some() && (a.character || a.paragraph) {
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -122,7 +122,7 @@ fn node_refs(n: &Node, f: Visit) {
|
||||
f(Ref::Name(Res::CharStyle, s));
|
||||
}
|
||||
}
|
||||
if let Some(s) = &t.para.style_name {
|
||||
for s in t.para_styles().filter_map(|pa| pa.style_name.as_ref()) {
|
||||
f(Ref::Name(Res::ParaStyle, s));
|
||||
}
|
||||
}
|
||||
@@ -440,8 +440,10 @@ impl Relink {
|
||||
self.name(Res::CharStyle, s);
|
||||
}
|
||||
}
|
||||
if let Some(s) = &mut t.para.style_name {
|
||||
self.name(Res::ParaStyle, s);
|
||||
for pa in t.para_styles_mut() {
|
||||
if let Some(s) = &mut pa.style_name {
|
||||
self.name(Res::ParaStyle, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => self.name(Res::Image, &mut im.key),
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default)}",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -180,6 +180,9 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector
|
||||
|
||||
// ---------- text ----------
|
||||
|
||||
/// Largest indent or paragraph spacing (points; Illustrator's limit).
|
||||
const MAX_PARA: f64 = 1296.0;
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setFormat";
|
||||
let ids: Vec<NodeId> = {
|
||||
@@ -268,11 +271,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
|
||||
}),
|
||||
};
|
||||
let range = super::typecmd::TextRange::parse(p, C)?;
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
for r in &mut t.runs {
|
||||
let st = &mut r.style;
|
||||
super::typecmd::style_chars(t, range, |st| {
|
||||
if let Some(k) = kerning {
|
||||
st.kerning = k;
|
||||
}
|
||||
@@ -306,41 +309,43 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
}
|
||||
let para = &mut t.para;
|
||||
if let Some(v) = num("leftIndent") {
|
||||
para.left_indent = v;
|
||||
}
|
||||
if let Some(v) = num("rightIndent") {
|
||||
para.right_indent = v;
|
||||
}
|
||||
if let Some(v) = num("firstLineIndent") {
|
||||
para.first_line_indent = v;
|
||||
}
|
||||
if let Some(v) = num("spaceBefore") {
|
||||
para.space_before = v;
|
||||
}
|
||||
if let Some(v) = num("spaceAfter") {
|
||||
para.space_after = v;
|
||||
}
|
||||
if let Some(v) = flag("hyphenate") {
|
||||
para.hyphenate = v;
|
||||
}
|
||||
if let Some(v) = mojikumi {
|
||||
para.mojikumi = v;
|
||||
}
|
||||
if let Some(v) = direction {
|
||||
para.direction = v;
|
||||
}
|
||||
if let Some(v) = leading_model {
|
||||
para.leading_model = v;
|
||||
}
|
||||
if let Some(v) = burasagari {
|
||||
para.burasagari = v;
|
||||
}
|
||||
if let Some(v) = composer {
|
||||
para.composer = v;
|
||||
}
|
||||
});
|
||||
let span = super::typecmd::para_span(range, t);
|
||||
t.edit_paras(span, |para| {
|
||||
if let Some(v) = num("leftIndent") {
|
||||
para.left_indent = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = num("rightIndent") {
|
||||
para.right_indent = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = num("firstLineIndent") {
|
||||
para.first_line_indent = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = num("spaceBefore") {
|
||||
para.space_before = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = num("spaceAfter") {
|
||||
para.space_after = v.clamp(-MAX_PARA, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = flag("hyphenate") {
|
||||
para.hyphenate = v;
|
||||
}
|
||||
if let Some(v) = mojikumi {
|
||||
para.mojikumi = v;
|
||||
}
|
||||
if let Some(v) = direction {
|
||||
para.direction = v;
|
||||
}
|
||||
if let Some(v) = leading_model {
|
||||
para.leading_model = v;
|
||||
}
|
||||
if let Some(v) = burasagari {
|
||||
para.burasagari = v;
|
||||
}
|
||||
if let Some(v) = composer {
|
||||
para.composer = v;
|
||||
}
|
||||
});
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TabAlign, TabStop};
|
||||
|
||||
use super::typecmd::{refresh_bounds, text_targets};
|
||||
use super::typecmd::{TextRange, para_span, refresh_bounds, text_targets};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -13,12 +13,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Tab Stops",
|
||||
["Window", "Tabs"],
|
||||
None,
|
||||
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?} replace the tab stops of the selected text",
|
||||
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches)",
|
||||
has_selection,
|
||||
set
|
||||
),
|
||||
cmd!(query "text.tabs.get", "Tab Stops", [], None, "{ids?} → {stops} of the first selected text object", has_selection, get),
|
||||
cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?}", has_selection, |s, p| apply(s, p, vec![], "Clear All Tabs")),
|
||||
cmd!(
|
||||
query "text.tabs.get",
|
||||
"Tab Stops",
|
||||
[],
|
||||
None,
|
||||
"{ids?, start?: byte} → {stops} of the first selected text object (its paragraph at `start`; default the first)",
|
||||
has_selection,
|
||||
get
|
||||
),
|
||||
cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?, start?: byte, end?: byte}", has_selection, |s, p| apply(
|
||||
s,
|
||||
p,
|
||||
vec![],
|
||||
"Clear All Tabs"
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -54,11 +67,13 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> {
|
||||
let ids = text_targets(s, p, "text.tabs.set")?;
|
||||
let range = TextRange::parse(p, "text.tabs.set")?;
|
||||
let n = stops.len();
|
||||
s.edit(label, |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.para.tabs = stops.clone();
|
||||
let span = para_span(range, t);
|
||||
t.edit_paras(span, |pa| pa.tabs = stops.clone());
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -69,9 +84,10 @@ fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = text_targets(s, p, "text.tabs.get")?;
|
||||
let range = TextRange::parse(p, "text.tabs.get")?;
|
||||
let st = s.doc()?;
|
||||
let tabs = ids.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t.para.tabs.clone()),
|
||||
Some(NodeKind::Text(t)) => Some(t.para_at(para_span(range, t).map_or(0, |r| r.start)).tabs.clone()),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({ "stops": serde_json::to_value(tabs.unwrap_or_default()).unwrap_or(Value::Null) }))
|
||||
|
||||
@@ -135,6 +135,13 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let insert = str_param(p, "insert").unwrap_or("").to_string();
|
||||
let caret = s.edit("Typing", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
// Paragraph styles follow the edit: a split paragraph's new one continues its style, a
|
||||
// merge keeps the first paragraph's.
|
||||
let inserted: String = match &styled {
|
||||
Some(r) => r.iter().map(|r| r.text.as_str()).collect(),
|
||||
None => insert.clone(),
|
||||
};
|
||||
t.splice_paras(a, b, &inserted);
|
||||
let caret = match &styled {
|
||||
Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r),
|
||||
None => edit::replace_range(&mut t.runs, a, b, &insert),
|
||||
@@ -463,6 +470,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
xf: Affine::IDENTITY,
|
||||
runs: vec![TextRun { text, style }],
|
||||
para: super::create::new_type_para(),
|
||||
paras: Vec::new(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
@@ -600,6 +608,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
xf: Affine::IDENTITY,
|
||||
runs: head.clone(),
|
||||
para: Default::default(),
|
||||
paras: Vec::new(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
|
||||
@@ -66,7 +66,7 @@ macro_rules! style_cmds {
|
||||
concat!($p, ".apply"),
|
||||
concat!("Apply ", $what, " Style"),
|
||||
&[],
|
||||
"{name, clearOverrides?, ids?|id + start?/end? (a Type tool range)} → {count}",
|
||||
"{name, clearOverrides?, ids?|id + start?/end? (a Type tool range: character styles style it, paragraph styles apply to the paragraphs it touches)} → {count}",
|
||||
|s, p| apply(s, p, $kind),
|
||||
true,
|
||||
),
|
||||
@@ -179,7 +179,7 @@ fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextOb
|
||||
}
|
||||
}
|
||||
|
||||
/// How many runs (char) or text objects (para) use `name`.
|
||||
/// How many runs (char) or paragraphs (para) use `name`.
|
||||
fn uses(d: &Document, kind: Kind, name: &str) -> usize {
|
||||
let target = (name != kind.normal()).then_some(name);
|
||||
let mut n = 0;
|
||||
@@ -187,7 +187,7 @@ fn uses(d: &Document, kind: Kind, name: &str) -> usize {
|
||||
if let NodeKind::Text(t) = &node.kind {
|
||||
n += match kind {
|
||||
Kind::Char => t.runs.iter().filter(|r| r.style.style_name.as_deref() == target).count(),
|
||||
Kind::Para => usize::from(t.para.style_name.as_deref() == target),
|
||||
Kind::Para => (0..t.paragraph_count()).filter(|&i| t.para_at(i).style_name.as_deref() == target).count(),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -210,9 +210,11 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
Kind::Para => {
|
||||
if t.para.style_name == target {
|
||||
t.para = restyle(&t.para, old, new);
|
||||
changed = true;
|
||||
for pa in t.para_styles_mut() {
|
||||
if pa.style_name == target {
|
||||
*pa = restyle(pa, old, new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,12 +233,11 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
|
||||
None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()),
|
||||
};
|
||||
let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) };
|
||||
let at = p.get("start").and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
|
||||
Ok(Some(match kind {
|
||||
Kind::Char => {
|
||||
let at = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at))
|
||||
}
|
||||
Kind::Para => to_attrs(&t.para),
|
||||
Kind::Char => to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at)),
|
||||
// The paragraph at `start`.
|
||||
Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, at).start)),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -329,8 +330,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
let normal = kind.attrs(d, kind.normal()).unwrap_or_default();
|
||||
let clear = bool_or(p, "clearOverrides", false);
|
||||
let style_name = (name != kind.normal()).then(|| name.clone());
|
||||
// A Type tool range (character styles only) or whole text objects.
|
||||
let range = id_param(p, "id").filter(|_| kind == Kind::Char && (p.get("start").is_some() || p.get("end").is_some()));
|
||||
// A Type tool range or whole text objects.
|
||||
let text_range = super::typecmd::TextRange::parse(p, &c)?;
|
||||
let range = id_param(p, "id").filter(|_| text_range.is_some());
|
||||
let ids = match range.or_else(|| id_param(p, "id")) {
|
||||
Some(id) => vec![id],
|
||||
None => text_targets(s, p, &c)?,
|
||||
@@ -376,7 +378,17 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Kind::Para => t.para = para_fn(&t.para)?,
|
||||
Kind::Para => {
|
||||
let mut v = t.paragraph_styles();
|
||||
let span = match (range, text_range) {
|
||||
(Some(_), Some(r)) => r.paras(t),
|
||||
_ => 0..v.len(),
|
||||
};
|
||||
for st in v.iter_mut().take(span.end).skip(span.start) {
|
||||
*st = para_fn(st)?;
|
||||
}
|
||||
t.set_paragraph_styles(v);
|
||||
}
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -447,7 +459,7 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
};
|
||||
match kind {
|
||||
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
|
||||
Kind::Para => fix(&mut t.para.style_name),
|
||||
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -472,7 +484,7 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
};
|
||||
match kind {
|
||||
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
|
||||
Kind::Para => fix(&mut t.para.style_name),
|
||||
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -75,14 +75,24 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
});
|
||||
// The story: live frames' runs, plus the text of frames deleted by this edit.
|
||||
let mut story: Vec<TextRun> = vec![];
|
||||
let mut sources: Vec<TextObject> = vec![];
|
||||
let mut later_wins = vec![];
|
||||
let mut live = vec![];
|
||||
for id in &thread {
|
||||
match (text_of(doc, *id), text_of(before, *id)) {
|
||||
(Some(t), _) => {
|
||||
(Some(t), old) => {
|
||||
story.extend(t.runs.iter().cloned());
|
||||
// A frame whose first paragraph was just restyled gives the paragraph it
|
||||
// shares with the frame before it that style.
|
||||
later_wins.push(old.is_some_and(|o| o.para_at(0) != t.para_at(0)));
|
||||
sources.push(t.clone());
|
||||
live.push(*id);
|
||||
}
|
||||
(None, Some(old)) => story.extend(old.runs.iter().cloned()),
|
||||
(None, Some(old)) => {
|
||||
story.extend(old.runs.iter().cloned());
|
||||
later_wins.push(false);
|
||||
sources.push(old.clone());
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +102,8 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let source_refs: Vec<&TextObject> = sources.iter().collect();
|
||||
let paras = vectorcraft_text::thread::story_paras(&source_refs, &later_wins);
|
||||
vectorcraft_text::edit::normalize(&mut story);
|
||||
if live.len() >= 2 {
|
||||
// Threaded frames keep their size: Auto Size is off for them (as in Illustrator).
|
||||
@@ -105,13 +117,18 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
}
|
||||
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
|
||||
let refs: Vec<&TextObject> = frames.iter().collect();
|
||||
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story);
|
||||
for (id, runs) in live.iter().zip(parts) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
||||
&& t.runs != runs
|
||||
{
|
||||
t.runs = runs;
|
||||
refresh_bounds(t);
|
||||
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, ¶s);
|
||||
for (id, part) in live.iter().zip(parts) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) {
|
||||
let mut next = (**t).clone();
|
||||
next.runs = part.runs;
|
||||
next.set_paragraph_styles(part.paras);
|
||||
if next.runs != t.runs || next.para != t.para || next.paras != t.paras {
|
||||
t.runs = next.runs;
|
||||
t.para = next.para;
|
||||
t.paras = next.paras;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if live.len() >= 2 {
|
||||
@@ -193,15 +210,17 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let mut story: Vec<TextRun> = vec![];
|
||||
for id in &thread {
|
||||
if let Some(t) = text_of(d, *id) {
|
||||
story.extend(t.runs.iter().cloned());
|
||||
}
|
||||
let sources: Vec<TextObject> = thread.iter().filter_map(|id| text_of(d, *id).cloned()).collect();
|
||||
for t in &sources {
|
||||
story.extend(t.runs.iter().cloned());
|
||||
}
|
||||
let paras = vectorcraft_text::thread::story_paras(&sources.iter().collect::<Vec<_>>(), &[]);
|
||||
let style = story.first().map(|r| r.style.clone()).unwrap_or_default();
|
||||
let first_para = paras.first().cloned().unwrap_or_default();
|
||||
for id in &out {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.runs = vec![TextRun { text: String::new(), style: style.clone() }];
|
||||
t.set_all_paras(first_para.clone());
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -210,6 +229,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
{
|
||||
vectorcraft_text::edit::normalize(&mut story);
|
||||
t.runs = story;
|
||||
t.set_paragraph_styles(paras);
|
||||
}
|
||||
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
|
||||
d.text_threads[i] = keep;
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -102,6 +102,8 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
|
||||
/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo
|
||||
/// step as the edit.
|
||||
pub(crate) fn refresh_bounds(t: &mut TextObject) {
|
||||
// Edits that changed the paragraph count keep one paragraph style per paragraph.
|
||||
t.normalize_paras();
|
||||
auto_height(t);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
@@ -255,6 +257,60 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
|
||||
/// paragraphs it touches. Without either, the command applies to all the text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct TextRange {
|
||||
start: Option<usize>,
|
||||
end: Option<usize>,
|
||||
}
|
||||
|
||||
impl TextRange {
|
||||
/// `start`/`end` of `p`, if either is given (non-negative integers).
|
||||
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Option<Self>> {
|
||||
let get = |k: &str| -> Result<Option<usize>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
v.as_u64().map(|n| Some(usize::try_from(n).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, format!("`{k}` must be a byte offset")))
|
||||
}
|
||||
}
|
||||
};
|
||||
let (start, end) = (get("start")?, get("end")?);
|
||||
Ok((start.is_some() || end.is_some()).then_some(Self { start, end }))
|
||||
}
|
||||
/// The byte range in `t`, clamped and ordered.
|
||||
pub(crate) fn bytes(&self, t: &TextObject) -> (usize, usize) {
|
||||
let len = vectorcraft_text::edit::runs_len(&t.runs);
|
||||
let a = self.start.unwrap_or(0).min(len);
|
||||
let b = self.end.unwrap_or(len).min(len);
|
||||
(a.min(b), a.max(b))
|
||||
}
|
||||
/// The paragraphs of `t` the range touches.
|
||||
pub(crate) fn paras(&self, t: &TextObject) -> std::ops::Range<usize> {
|
||||
let (a, b) = self.bytes(t);
|
||||
t.paragraphs_in(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraphs of `t` an optional range touches (None: every paragraph).
|
||||
pub(crate) fn para_span(range: Option<TextRange>, t: &TextObject) -> Option<std::ops::Range<usize>> {
|
||||
range.map(|r| r.paras(t))
|
||||
}
|
||||
|
||||
/// Apply `f` to the character styles of `range` of `t` (None: every run).
|
||||
pub(crate) fn style_chars(t: &mut TextObject, range: Option<TextRange>, mut f: impl FnMut(&mut vectorcraft_doc::CharStyle)) {
|
||||
match range {
|
||||
None => t.runs.iter_mut().for_each(|r| f(&mut r.style)),
|
||||
Some(r) => {
|
||||
let (a, b) = r.bytes(t);
|
||||
if a < b {
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string();
|
||||
let ids = text_targets(s, p, "text.setText")?;
|
||||
@@ -262,6 +318,8 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let style = t.first_style();
|
||||
// Every paragraph of the new text takes the first paragraph's attributes.
|
||||
t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text);
|
||||
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style }];
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -319,14 +377,14 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let range = TextRange::parse(p, C)?;
|
||||
let ids = text_targets(s, p, C)?;
|
||||
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let before = protect.then(|| t.runs.clone());
|
||||
for r in &mut t.runs {
|
||||
let st = &mut r.style;
|
||||
style_chars(t, range, |st| {
|
||||
if let Some(f) = &font {
|
||||
st.font_family = f.clone();
|
||||
}
|
||||
@@ -348,12 +406,13 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = &features {
|
||||
st.features = f.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(before) = before {
|
||||
super::textedit::protect_missing_glyphs(&before, &mut t.runs);
|
||||
}
|
||||
if let Some(j) = justify {
|
||||
t.para.justify = j;
|
||||
let span = para_span(range, t);
|
||||
t.edit_paras(span, |pa| pa.justify = j);
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -788,7 +847,8 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe);
|
||||
let cell = lay.frames.first()?;
|
||||
let line = lay.lines.first()?;
|
||||
let avail = cell.width() - 2.0 * t.area.inset - t.para.left_indent - t.para.right_indent;
|
||||
let para = t.para_at(0);
|
||||
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
|
||||
Some((line.x1 - line.x0, avail, lay.lines.iter().filter(|l| l.start < para_end).count()))
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
|
||||
let slack = size * 0.5;
|
||||
// Slack goes where the alignment leaves room, so the text doesn't move or rewrap.
|
||||
let (sl, sr) = match t.para.justify {
|
||||
let (sl, sr) = match t.para_at(0).justify {
|
||||
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
|
||||
Justify::Right | Justify::JustifyRight => (slack, 0.0),
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
|
||||
@@ -315,20 +315,22 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
breaks.sort_unstable();
|
||||
breaks.dedup();
|
||||
for b in breaks.into_iter().rev() {
|
||||
// Each wrapped line becomes a paragraph with its paragraph's attributes.
|
||||
if plain.as_bytes().get(b - 1) == Some(&b' ') {
|
||||
if let Some((ri, bi)) = locate(&t.runs, b - 1) {
|
||||
t.splice_paras(b - 1, b, "\n");
|
||||
t.runs[ri].text.replace_range(bi..bi + 1, "\n");
|
||||
}
|
||||
} else if let Some((ri, bi)) = locate(&t.runs, b) {
|
||||
t.splice_paras(b, b, "\n");
|
||||
t.runs[ri].text.insert(bi, '\n');
|
||||
}
|
||||
}
|
||||
// The point origin sits where the alignment anchors the first line.
|
||||
let (x0, x1) = lay
|
||||
.lines
|
||||
.first()
|
||||
.map_or((fb.x0, fb.x1), |l| (l.avail.0 - t.para.left_indent - t.para.first_line_indent, l.avail.1 + t.para.right_indent));
|
||||
let ox = match t.para.justify {
|
||||
let p0 = t.para_at(0);
|
||||
let (x0, x1) =
|
||||
lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent));
|
||||
let ox = match p0.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
|
||||
Justify::Right | Justify::JustifyRight => x1,
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
|
||||
@@ -646,7 +648,7 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
let text = t.plain_text();
|
||||
t.runs = vec![TextRun { text, style: CharStyle::default() }];
|
||||
t.para = Default::default();
|
||||
t.set_all_paras(Default::default());
|
||||
refresh_bounds(t);
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
|
||||
@@ -1431,6 +1431,8 @@ mod tests_paintproxy;
|
||||
#[cfg(test)]
|
||||
mod tests_panelcmds;
|
||||
#[cfg(test)]
|
||||
mod tests_paragraphs;
|
||||
#[cfg(test)]
|
||||
mod tests_pathops;
|
||||
#[cfg(test)]
|
||||
mod tests_pathtype;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Paragraph attributes per paragraph: ranged Paragraph panel commands, paragraph styles on one
|
||||
//! paragraph, splitting and merging paragraphs while editing, undo and threaded text.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Justify, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn area(s: &mut Session, text: &str) -> u64 {
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 300}})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: u64) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn justs(s: &Session, id: u64) -> Vec<Justify> {
|
||||
text(s, id).paragraph_styles().iter().map(|p| p.justify).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranged_paragraph_commands_touch_only_their_paragraphs() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, "one\ntwo\nthree");
|
||||
// A caret in "two" (byte 5): only the second paragraph.
|
||||
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
|
||||
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
|
||||
// A selection from "one" into "two": both.
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "spaceBefore": 9, "leftIndent": 4, "start": 1, "end": 5})).unwrap();
|
||||
let t = text(&s, id);
|
||||
let sb: Vec<f64> = t.paragraph_styles().iter().map(|p| p.space_before).collect();
|
||||
assert_eq!(sb, [9.0, 9.0, 0.0]);
|
||||
assert_eq!(t.para.left_indent, 4.0, "paragraph 0 is `para`");
|
||||
// Character attributes of setFormat follow the range too.
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "underline": true, "start": 4, "end": 7})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.runs.iter().any(|r| r.text == "two" && r.style.underline));
|
||||
assert!(t.runs.iter().any(|r| r.text.starts_with("one") && !r.style.underline));
|
||||
// The layout sees each paragraph's alignment.
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert!(lay.lines[1].x0 > lay.lines[0].x0 + 20.0, "the centred line sits right of the left one");
|
||||
// No range: every paragraph (as before).
|
||||
s.execute("text.setStyle", &json!({"id": id, "justify": "right"})).unwrap();
|
||||
assert_eq!(justs(&s, id), [Justify::Right; 3]);
|
||||
// Undo steps back through the ranged edits.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
|
||||
// Tabs per paragraph; a query reads the paragraph at `start`.
|
||||
s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 40}], "start": 9, "end": 9})).unwrap();
|
||||
assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id], "start": 9})).unwrap()["stops"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id]})).unwrap()["stops"], json!([]));
|
||||
// Junk ranges are errors, not panics.
|
||||
assert!(s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": -3})).is_err());
|
||||
assert!(s.execute("text.setStyle", &json!({"id": id, "justify": "left", "end": "x"})).is_err());
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": 1_000_000, "end": u64::MAX})).unwrap();
|
||||
assert_eq!(text(&s, id).para_at(2).space_after, 1.0, "past the end: the last paragraph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_splits_and_merges_paragraph_styles() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, "one\ntwo\nthree");
|
||||
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
|
||||
// A break inside "two": both halves keep its style.
|
||||
s.execute("text.editRange", &json!({"id": id, "start": 5, "end": 5, "insert": "\n"})).unwrap();
|
||||
assert_eq!(text(&s, id).plain_text(), "one\nt\nwo\nthree");
|
||||
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]);
|
||||
// Deleting the break after "one": the first paragraph's style wins.
|
||||
s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 4})).unwrap();
|
||||
assert_eq!(text(&s, id).plain_text(), "onet\nwo\nthree");
|
||||
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
|
||||
// Pasting styled runs with breaks in "wo": the new paragraphs continue it.
|
||||
let runs = json!([{"text": "A\nB\n", "style": text(&s, id).runs[0].style}]);
|
||||
s.execute("text.editRange", &json!({"id": id, "start": 6, "end": 6, "runs": runs})).unwrap();
|
||||
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Center, Justify::Auto]);
|
||||
// Undo restores the styles with the text.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]);
|
||||
// Replacing all the text: every paragraph takes the first one's attributes.
|
||||
s.execute("text.setText", &json!({"ids": [id], "text": "x\ny"})).unwrap();
|
||||
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Auto]);
|
||||
assert!(text(&s, id).paras.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn return_in_the_type_tool_continues_the_paragraph_it_splits() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let id = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "one\ntwo", "size": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.setStyle", &json!({"id": id, "justify": "right", "start": 0, "end": 0})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
|
||||
s.select_tool("type", v).unwrap();
|
||||
// Click into the first line (right-aligned: it ends at the origin), then End and Return.
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
|
||||
let l0 = &lay.lines[0];
|
||||
let (x, y) = (100.0 + (l0.x0 + l0.x1) / 2.0, 100.0 + l0.baseline - 5.0);
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
s.tool_key(ToolKey::End, Mods::default(), v).unwrap();
|
||||
s.tool_key(ToolKey::Enter, Mods::default(), v).unwrap();
|
||||
s.tool_text("new", v).unwrap();
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(text(&s, id).plain_text(), "one\nnew\ntwo");
|
||||
assert_eq!(justs(&s, id), [Justify::Right, Justify::Right, Justify::Center]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_applies_to_one_paragraph() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, "Title\nBody text");
|
||||
// The second paragraph keeps new type's attributes, whatever they are.
|
||||
let body = text(&s, id).para_at(1).clone();
|
||||
s.execute("paraStyle.new", &json!({"name": "Head", "attrs": {"justify": "Center", "space_after": 8.0}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Head", "id": id, "start": 2, "end": 2})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.para_at(0).style_name.as_deref(), Some("Head"));
|
||||
assert_eq!((t.para_at(0).justify, t.para_at(0).space_after), (Justify::Center, 8.0));
|
||||
assert_eq!(t.para_at(1), &body, "new type's");
|
||||
let list = s.execute("paraStyle.list", &json!({})).unwrap();
|
||||
assert_eq!(list["styles"][1]["uses"], 1, "one paragraph uses it");
|
||||
assert_eq!(list["styles"][0]["uses"], 1, "the other is Normal");
|
||||
// Redefining updates that paragraph only; renaming follows it.
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Head", "attrs": {"justify": "Right"}})).unwrap();
|
||||
assert_eq!(justs(&s, id), [Justify::Right, Justify::Auto]);
|
||||
s.execute("paraStyle.rename", &json!({"name": "Head", "to": "Heading"})).unwrap();
|
||||
assert_eq!(text(&s, id).para_at(0).style_name.as_deref(), Some("Heading"));
|
||||
// New from the selection reads the caret's paragraph.
|
||||
s.execute("paraStyle.new", &json!({"name": "From body", "id": id, "start": 8})).unwrap();
|
||||
let defs = &s.doc().unwrap().doc.para_styles;
|
||||
assert_eq!(defs.iter().find(|d| d.name == "From body").unwrap().attrs["justify"], "Auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threaded_frames_keep_each_paragraphs_style() {
|
||||
let mut s = session();
|
||||
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(3) + "\nSecond paragraph.\nThird.";
|
||||
let a =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let joined = |s: &Session| format!("{}{}", text(s, a).plain_text(), text(s, b).plain_text());
|
||||
assert_eq!(joined(&s), story);
|
||||
// Centre the second paragraph (in frame b) and right-align the third.
|
||||
let tb = text(&s, b).plain_text();
|
||||
let second = tb.find("Second").unwrap();
|
||||
s.execute("text.setStyle", &json!({"id": b, "justify": "center", "start": second, "end": second})).unwrap();
|
||||
let third = text(&s, b).plain_text().find("Third").unwrap();
|
||||
s.execute("text.setStyle", &json!({"id": b, "justify": "right", "start": third, "end": third})).unwrap();
|
||||
assert_eq!(justs(&s, b), [Justify::Auto, Justify::Center, Justify::Right]);
|
||||
// Typing in the first frame re-flows the story: the styles move with their paragraphs.
|
||||
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "More words at the start of the story. "})).unwrap();
|
||||
let (ja, jb) = (justs(&s, a), justs(&s, b));
|
||||
assert_eq!(ja, [Justify::Auto]);
|
||||
assert_eq!(jb, [Justify::Auto, Justify::Center, Justify::Right]);
|
||||
// A break in the first frame makes a new paragraph there; the later ones keep their styles.
|
||||
s.execute("text.editRange", &json!({"id": a, "start": 4, "end": 4, "insert": "\n"})).unwrap();
|
||||
let all: Vec<Justify> = [justs(&s, a), justs(&s, b)].concat();
|
||||
assert_eq!(all.iter().filter(|j| **j == Justify::Center).count(), 1);
|
||||
assert_eq!(all.last(), Some(&Justify::Right));
|
||||
// Restyling the paragraph that continues into frame b (its first) sticks after the re-flow.
|
||||
s.execute("text.setStyle", &json!({"id": b, "justify": "justifyAll", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(justs(&s, b)[0], Justify::JustifyAll);
|
||||
assert_eq!(*justs(&s, a).last().unwrap(), Justify::JustifyAll, "the paragraph's part in frame a follows");
|
||||
}
|
||||
|
||||
/// Upstream's paragraph attributes (direction, mojikumi, leading model) follow a range like the
|
||||
/// others: only the touched paragraphs change, and the layout reads each paragraph's own.
|
||||
#[test]
|
||||
fn ranged_set_format_sets_direction_mojikumi_and_leading_model_per_paragraph() {
|
||||
use vectorcraft_doc::{LeadingModel, Mojikumi, ParaDirection};
|
||||
let mut s = session();
|
||||
let id = area(&mut s, "Hello.\nHello.");
|
||||
// A caret in the second paragraph (byte 9).
|
||||
s.execute(
|
||||
"text.setFormat",
|
||||
&json!({"ids": [id], "direction": "rightToLeft", "leadingModel": "emBoxTop", "mojikumi": "none", "start": 9, "end": 9}),
|
||||
)
|
||||
.unwrap();
|
||||
let t = text(&s, id);
|
||||
let (p0, p1) = (t.para_at(0).clone(), t.para_at(1).clone());
|
||||
assert_eq!((p0.direction, p0.leading_model, p0.mojikumi), (None, LeadingModel::RomanBaseline, Mojikumi::LineEndHalf));
|
||||
assert_eq!((p1.direction, p1.leading_model, p1.mojikumi), (Some(ParaDirection::RightToLeft), LeadingModel::EmBoxTop, Mojikumi::None));
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert!(!lay.lines[0].rtl && lay.lines[1].rtl, "each paragraph's direction");
|
||||
// Return in the right-to-left paragraph continues it.
|
||||
s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nX"})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.para_at(2).direction, Some(ParaDirection::RightToLeft));
|
||||
// No range: every paragraph.
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "direction": "auto"})).unwrap();
|
||||
assert!(text(&s, id).paragraph_styles().iter().all(|p| p.direction.is_none()));
|
||||
}
|
||||
@@ -274,6 +274,15 @@ fn structured_junk() {
|
||||
("text.create", json!({"x": 0, "y": 0, "text": "\u{0}\u{FFFF}\u{1F600}", "size": -1})),
|
||||
("text.create", json!({"x": 0, "y": 0, "text": "a", "size": 1e308, "area": {"width": -1, "height": 0}})),
|
||||
("text.setStyle", json!({"size": 0, "leading": -1, "tracking": 1e308})),
|
||||
("text.setStyle", json!({"justify": "center", "start": u64::MAX, "end": 0})),
|
||||
("text.setStyle", json!({"justify": "right", "start": -1, "end": "x"})),
|
||||
("text.setFormat", json!({"spaceBefore": 1e308, "leftIndent": -1e308, "start": 3, "end": u64::MAX})),
|
||||
("text.setFormat", json!({"hyphenate": true, "underline": true, "start": 1.5, "end": null})),
|
||||
("text.setFormat", json!({"ids": [u64::MAX, 2], "firstLineIndent": 5, "start": 0, "end": 0})),
|
||||
("text.tabs.set", json!({"stops": [{"position": 10}], "start": u64::MAX, "end": u64::MAX})),
|
||||
("text.tabs.get", json!({"start": -5})),
|
||||
("paraStyle.apply", json!({"name": "[Normal Paragraph Style]", "id": 2, "start": u64::MAX, "end": 1})),
|
||||
("paraStyle.new", json!({"id": 2, "start": u64::MAX})),
|
||||
("document.setUnits", json!({"units": "Parsecs"})),
|
||||
("document.open", json!({"name": "x.svg", "dataBase64": "!!!"})),
|
||||
("document.open", json!({"name": "x.svg", "dataBase64": "PHN2Zz4="})),
|
||||
|
||||
@@ -549,6 +549,10 @@ impl Rules<'_> {
|
||||
if let Some(l) = st.leading {
|
||||
art.props.push(("line-height", self.len(l)));
|
||||
}
|
||||
// One element: the first paragraph's alignment.
|
||||
if (1..t.paragraph_count()).any(|i| t.para_at(i).justify != t.para.justify) {
|
||||
art.unsupported("paragraphs aligned differently");
|
||||
}
|
||||
let align = match t.para.justify {
|
||||
Justify::Auto | Justify::Left => None,
|
||||
Justify::Center => Some("center"),
|
||||
|
||||
+47
-21
@@ -1683,18 +1683,27 @@ impl Writer<'_> {
|
||||
}
|
||||
self.note_fonts(t, &lay);
|
||||
let lines = text_lines(t, &lay, self.opts.fewer_tspans);
|
||||
// A tab starts a new chunk at its stop: only lines without tabs can be anchored.
|
||||
let anchor = match t.para.justify {
|
||||
Justify::Center => Some(("middle", 0.5)),
|
||||
Justify::Right => Some(("end", 1.0)),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|_| !lines.iter().flatten().any(|s| s.brk));
|
||||
// Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its
|
||||
// stop: lines with tabs are never anchored.
|
||||
let anchors: Vec<Option<(&str, f64)>> = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
match t.para_at(l.para).justify {
|
||||
Justify::Center => Some(("middle", 0.5)),
|
||||
Justify::Right => Some(("end", 1.0)),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|_| !l.segs.iter().any(|s| s.brk))
|
||||
})
|
||||
.collect();
|
||||
// Every line anchored alike (the text's only paragraph style, typically): the <text>
|
||||
// carries the anchor. Otherwise each anchored line is a <tspan> carrying its own.
|
||||
let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten();
|
||||
// The <text> element's user space is text space.
|
||||
let space = (lay.bounds, Affine::IDENTITY);
|
||||
let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: anchor.is_some() };
|
||||
let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: uniform.is_some() };
|
||||
let mut props = base.props.clone();
|
||||
if let Some((a, _)) = anchor {
|
||||
if let Some((a, _)) = uniform {
|
||||
props.push(("text-anchor", a.into()));
|
||||
}
|
||||
props.extend(css::transparency(n));
|
||||
@@ -1702,7 +1711,7 @@ impl Writer<'_> {
|
||||
let a = self.attrs(&props);
|
||||
let m = self.xf * t.xf;
|
||||
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
|
||||
let starts: Vec<Option<(f64, f64)>> = lines.iter().map(|l| line_start(l, anchor.map(|a| a.1))).collect();
|
||||
let starts: Vec<Option<(f64, f64)>> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect();
|
||||
// The `<text>` carries the first line's position too, so readers that place text by its
|
||||
// own x/y start where the first line does.
|
||||
let at = match starts.iter().flatten().next() {
|
||||
@@ -1710,9 +1719,17 @@ impl Writer<'_> {
|
||||
None => String::new(),
|
||||
};
|
||||
let mut s = format!("<text{id}{tr}{at} xml:space=\"preserve\"{a}>");
|
||||
for (line, start) in lines.iter().zip(starts) {
|
||||
if let Some(start) = start {
|
||||
self.text_line(&mut s, t, line, start, &base);
|
||||
for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) {
|
||||
let Some(start) = start else { continue };
|
||||
match anchor.filter(|_| uniform.is_none()) {
|
||||
// A line anchored on its own: one positioned <tspan> with the anchor around it.
|
||||
Some((a, _)) => {
|
||||
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\" text-anchor=\"{a}\">", self.num(start.0), self.num(start.1)));
|
||||
let inner = TextBase { anchored: true, ..base.clone() };
|
||||
self.text_line(&mut s, t, &line.segs, start, &inner, false);
|
||||
s.push_str("</tspan>");
|
||||
}
|
||||
None => self.text_line(&mut s, t, &line.segs, start, &base, true),
|
||||
}
|
||||
}
|
||||
s.push_str("</text>");
|
||||
@@ -1723,12 +1740,13 @@ impl Writer<'_> {
|
||||
/// (only the first of an anchored line). Fewer tspans: one positioned `<tspan>` per line with
|
||||
/// the style changes nested in it, positioned again only after tabs and justified word spaces.
|
||||
/// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted.
|
||||
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase) {
|
||||
let fewer = self.opts.fewer_tspans;
|
||||
/// `positioned`: false when the caller already positioned the line (an outer `<tspan>`).
|
||||
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) {
|
||||
let fewer = self.opts.fewer_tspans && positioned;
|
||||
if fewer {
|
||||
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\">", self.num(start.0), self.num(start.1)));
|
||||
}
|
||||
let mut place = !fewer;
|
||||
let mut place = !fewer && positioned;
|
||||
let mut first = true;
|
||||
// The baseline shift the current text position carries.
|
||||
let mut shift = 0.0;
|
||||
@@ -1746,7 +1764,7 @@ impl Writer<'_> {
|
||||
shift = st.baseline_shift;
|
||||
}
|
||||
// After a tab or a justified word space the next segment is placed.
|
||||
place = seg.brk || (!fewer && !base.anchored);
|
||||
place = seg.brk || (!fewer && !base.anchored && positioned);
|
||||
if (st.h_scale - st.v_scale).abs() > 1e-9 {
|
||||
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance)));
|
||||
}
|
||||
@@ -1848,7 +1866,7 @@ impl Writer<'_> {
|
||||
let space = (lay.bounds, self.xf * t.xf);
|
||||
let base = self.char_props(&t.first_style(), space);
|
||||
let mut props = base.clone();
|
||||
match t.para.justify {
|
||||
match t.para_at(0).justify {
|
||||
Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())),
|
||||
Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())),
|
||||
_ => {}
|
||||
@@ -1917,6 +1935,7 @@ fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
|
||||
}
|
||||
|
||||
/// What the lines of one `<text>` share.
|
||||
#[derive(Clone)]
|
||||
struct TextBase {
|
||||
/// The `<text>` element's character properties, which each `<tspan>` differs from.
|
||||
props: Props,
|
||||
@@ -1926,6 +1945,12 @@ struct TextBase {
|
||||
anchored: bool,
|
||||
}
|
||||
|
||||
/// One laid-out line: its paragraph (index) and its segments.
|
||||
struct Line {
|
||||
para: usize,
|
||||
segs: Vec<Segment>,
|
||||
}
|
||||
|
||||
/// Characters of one style on one line, written as one `<tspan>`.
|
||||
struct Segment {
|
||||
/// Index into `TextObject::runs`.
|
||||
@@ -1944,11 +1969,12 @@ struct Segment {
|
||||
/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters
|
||||
/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all
|
||||
/// caps are upper case.
|
||||
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Vec<Segment>> {
|
||||
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Line> {
|
||||
let plain = t.plain_text();
|
||||
let split_words = !fewer && !matches!(t.para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
|
||||
let mut lines = Vec::with_capacity(lay.lines.len());
|
||||
for line in &lay.lines {
|
||||
let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count());
|
||||
let split_words = !fewer && !matches!(t.para_at(para).justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
|
||||
let mut segs: Vec<Segment> = Vec::new();
|
||||
let mut cluster = None;
|
||||
let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
|
||||
@@ -1983,7 +2009,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
}
|
||||
segs.retain(|s| !s.text.is_empty());
|
||||
if !segs.is_empty() {
|
||||
lines.push(segs);
|
||||
lines.push(Line { para, segs });
|
||||
}
|
||||
}
|
||||
lines
|
||||
|
||||
@@ -30,7 +30,7 @@ use std::str::FromStr;
|
||||
|
||||
use usvg::roxmltree;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, StrokeLayer, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, ParaStyle, StrokeLayer, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
|
||||
use vectorcraft_text::{FontDb, TextLayout};
|
||||
@@ -556,11 +556,12 @@ impl Ctx {
|
||||
.collect();
|
||||
let under = stroke_under(&mut cells, label, warnings);
|
||||
|
||||
let anchor = match self.css.prop(cs.chars[0].1, "text-anchor").as_deref() {
|
||||
let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.css.prop(c.1, "text-anchor")).as_deref() {
|
||||
Some("middle") => 0.5,
|
||||
Some("end") => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let anchor = anchor_of(0);
|
||||
let path = cs.path.and_then(|tp| match self.text_path(xml, tp) {
|
||||
Some(p) => Some((tp, p)),
|
||||
None => {
|
||||
@@ -571,6 +572,7 @@ impl Ctx {
|
||||
let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
|
||||
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
|
||||
let mut breaks: Vec<Break> = vec![];
|
||||
let mut para_styles: Vec<ParaStyle> = vec![];
|
||||
if let Some((tp, bp)) = path {
|
||||
// Along the path: dx is extra advance, dy shifts off the path.
|
||||
let mut off = 0.0;
|
||||
@@ -610,18 +612,38 @@ impl Ctx {
|
||||
obj.xf = Affine::IDENTITY;
|
||||
} else {
|
||||
let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]);
|
||||
let (justify, left) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks);
|
||||
let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks);
|
||||
obj.para.justify = justify;
|
||||
obj.xf = Affine::translate((finite(left), finite(origin.y)));
|
||||
// Lines anchored differently (paragraphs aligned differently): each line's paragraph
|
||||
// takes its alignment, and indents that put it where it was.
|
||||
if let Some(starts) = starts.filter(|st| st.iter().any(|(i, _)| anchor_of(*i) != anchor)) {
|
||||
for (k, &(i, ax)) in starts.iter().enumerate() {
|
||||
let a = anchor_of(i);
|
||||
let d = finite(ax - left);
|
||||
let mut pa = ParaStyle { justify: justify_of(a), ..ParaStyle::default() };
|
||||
match pa.justify {
|
||||
Justify::Center if d >= 0.0 => pa.left_indent = 2.0 * d,
|
||||
Justify::Center => pa.right_indent = -2.0 * d,
|
||||
Justify::Right => pa.right_indent = -d,
|
||||
_ => pa.left_indent = d,
|
||||
}
|
||||
let count = if k == 0 { 1 } else { breaks.get(k - 1).map_or(1, |b| b.count) };
|
||||
para_styles.extend(std::iter::repeat_n(pa, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (runs, servers) = assemble(&cells, &breaks).0;
|
||||
obj.runs = runs;
|
||||
if !para_styles.is_empty() {
|
||||
obj.set_paragraph_styles(para_styles);
|
||||
}
|
||||
// SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first
|
||||
// strong character: pinned when that would read as right to left.
|
||||
// strong character: pinned (on every paragraph) when that would read as right to left.
|
||||
if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") {
|
||||
obj.para.direction = Some(ParaDirection::RightToLeft);
|
||||
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft));
|
||||
} else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) {
|
||||
obj.para.direction = Some(ParaDirection::LeftToRight);
|
||||
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight));
|
||||
}
|
||||
// The placeholder covers the text (laid out when a paint server needs its bounding box).
|
||||
let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default();
|
||||
@@ -708,8 +730,18 @@ fn finite(v: f64) -> f64 {
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The alignment of an SVG text chunk anchored at `anchor` (0 start, 0.5 middle, 1 end).
|
||||
fn justify_of(anchor: f64) -> Justify {
|
||||
match anchor {
|
||||
a if a >= 1.0 => Justify::Right,
|
||||
a if a > 0.0 => Justify::Center,
|
||||
_ => Justify::Left,
|
||||
}
|
||||
}
|
||||
|
||||
/// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the
|
||||
/// justification and the x of the first line's left (or anchor) edge.
|
||||
/// justification, the x of the first line's left (or anchor) edge and, when no character is
|
||||
/// placed on its own, each line's first character and anchor x.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn lines(
|
||||
cells: &mut [Cell],
|
||||
@@ -720,7 +752,7 @@ fn lines(
|
||||
anchor: f64,
|
||||
x0: f64,
|
||||
breaks: &mut Vec<Break>,
|
||||
) -> (Justify, f64) {
|
||||
) -> (Justify, f64, Option<Vec<(usize, f64)>>) {
|
||||
let n = cells.len();
|
||||
let y0 = y[0].unwrap_or(0.0) + dy[0];
|
||||
let (mut base, mut cur) = (y0, y0);
|
||||
@@ -758,12 +790,7 @@ fn lines(
|
||||
}
|
||||
}
|
||||
if targets.is_empty() {
|
||||
let justify = match anchor {
|
||||
a if a >= 1.0 => Justify::Right,
|
||||
a if a > 0.0 => Justify::Center,
|
||||
_ => Justify::Left,
|
||||
};
|
||||
return (justify, x0);
|
||||
return (justify_of(anchor), x0, Some(lines));
|
||||
}
|
||||
// Absolutely placed characters: lay the text out left-aligned and kern each one onto its x.
|
||||
// SVG anchors every absolutely placed chunk on its own, so with a middle/end anchor each
|
||||
@@ -819,7 +846,7 @@ fn lines(
|
||||
break;
|
||||
}
|
||||
}
|
||||
(Justify::Left, left)
|
||||
(Justify::Left, left, None)
|
||||
}
|
||||
|
||||
/// A left-aligned point text of `runs` at the origin (for measuring).
|
||||
|
||||
@@ -200,3 +200,37 @@ fn a_viewer_draws_the_text_where_the_canvas_does() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_aligned_differently_keep_their_alignment() {
|
||||
let mut t = TextObject::point(Point::new(20.0, 20.0), "Left words here\nCentred line\nRight line", style(14.0));
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 160.0)) };
|
||||
let para = |j| vectorcraft_doc::ParaStyle { justify: j, ..Default::default() };
|
||||
t.set_paragraph_styles(vec![para(Justify::Left), para(Justify::Center), para(Justify::Right)]);
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
for fewer_tspans in [false, true] {
|
||||
let svg = export(&d, &ExportOptions { fewer_tspans, ..Default::default() });
|
||||
// Each anchored line carries its own anchor (the <text> has none).
|
||||
assert!(svg.contains("text-anchor=\"middle\"") && svg.contains("text-anchor=\"end\""), "{svg}");
|
||||
assert!(!svg.contains("<text text-anchor"), "{svg}");
|
||||
// A viewer draws each line where the canvas does.
|
||||
let mut viewer = Document::new(300.0, 200.0);
|
||||
let vl = viewer.layers[0].id;
|
||||
for bp in viewer_outlines(&svg) {
|
||||
let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let n = Node::path(viewer.alloc_id(), PathData::from_bezpath(&bp), ap);
|
||||
viewer.insert(Some(vl), usize::MAX, n).unwrap();
|
||||
}
|
||||
assert_similar(&render_artboard(&d), &render_artboard(&viewer), 40.0, 0.01);
|
||||
// Read back: one paragraph per line, each with its alignment, drawn where it was.
|
||||
let back = vectorcraft_svg::import(&svg).unwrap();
|
||||
let text =
|
||||
back.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.clone()) } else { None }).unwrap();
|
||||
let js: Vec<Justify> = text.paragraph_styles().iter().map(|p| p.justify).collect();
|
||||
assert_eq!(js, [Justify::Left, Justify::Center, Justify::Right], "{svg}");
|
||||
assert_similar(&render_artboard(&d), &render_artboard(&back), 40.0, 0.01);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,15 @@ pub fn rich_session() -> Session {
|
||||
json!({"x": 300, "y": 330, "size": 14, "text": "Area type shrinks its text to fit the frame.",
|
||||
"area": {"width": 150, "height": 40, "fit": "shrinkText", "fitMinPercent": 40}}),
|
||||
));
|
||||
// Area type whose paragraphs differ (alignment, spacing, tabs).
|
||||
let para = id_of(&exec(
|
||||
&mut s,
|
||||
"text.create",
|
||||
json!({"x": 20, "y": 360, "text": "Heading\nBody one\tx\nBody two", "size": 9, "area": {"width": 160, "height": 36}}),
|
||||
));
|
||||
exec(&mut s, "text.setStyle", json!({"id": para, "justify": "center", "start": 0, "end": 0}));
|
||||
exec(&mut s, "text.setFormat", json!({"ids": [para], "spaceBefore": 3, "firstLineIndent": 6, "start": 9, "end": 9}));
|
||||
exec(&mut s, "text.tabs.set", json!({"ids": [para], "stops": [{"position": 80, "align": "right"}], "start": 9, "end": 9}));
|
||||
exec(&mut s, "layer.new", json!({"name": "Top"}));
|
||||
let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0);
|
||||
let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0);
|
||||
|
||||
@@ -250,7 +250,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
|
||||
_ => QUARTER_TURN,
|
||||
};
|
||||
match &t.kind {
|
||||
TextKind::Point => flow(&mut cx, ¶s, &t.para, None),
|
||||
TextKind::Point => flow(&mut cx, ¶s, t, None),
|
||||
TextKind::Area { frame } => {
|
||||
let to_lines = line_xf.inverse();
|
||||
let wrap: Vec<vectorcraft_doc::WrapShape> = if vertical {
|
||||
@@ -260,9 +260,9 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
|
||||
};
|
||||
let mut regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
|
||||
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
|
||||
flow(&mut cx, ¶s, &t.para, Some(®ions));
|
||||
flow(&mut cx, ¶s, t, Some(®ions));
|
||||
if opts.vertical_align != VerticalAlign::Top {
|
||||
align_vertically(&mut cx, ¶s, &t.para, &mut regions);
|
||||
align_vertically(&mut cx, ¶s, t, &mut regions);
|
||||
}
|
||||
}
|
||||
TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path),
|
||||
@@ -526,7 +526,7 @@ fn cell_space(out: &TextLayout, regions: &[Region]) -> Vec<Option<(f64, f64, usi
|
||||
/// Other frames give lines different widths at different heights, so the text flows again with
|
||||
/// each cell's lines started lower (or spaced wider) until it settles, at most eight passes; a
|
||||
/// pass that would push text out of the frame is undone and retried with half the step.
|
||||
fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: &mut [Region]) {
|
||||
fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: &mut [Region]) {
|
||||
let align = cx.opts.vertical_align;
|
||||
let plain = regions.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty());
|
||||
if plain {
|
||||
@@ -596,7 +596,7 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle,
|
||||
}
|
||||
let before = laid_out(&cx.out);
|
||||
let prev = (std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::replace(&mut cx.out.overflow, false));
|
||||
flow(cx, paras, para, Some(regions));
|
||||
flow(cx, paras, t, Some(regions));
|
||||
if laid_out(&cx.out) < before {
|
||||
// Text no longer fits (lines got narrower, or flow around a wrap object): undo the
|
||||
// pass and try a smaller step.
|
||||
@@ -1291,7 +1291,9 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
|
||||
compose(sg, &width, &start_credit, &cands, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, ¶ms(loose)))
|
||||
}
|
||||
|
||||
fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Option<&[Region]>) {
|
||||
/// Flow paragraphs `paras` (byte ranges) of `t`, each with its own paragraph attributes
|
||||
/// (alignment, indents, spacing, direction, mojikumi, leading model…).
|
||||
fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Option<&[Region]>) {
|
||||
let mut pen = Pen {
|
||||
regions,
|
||||
ri: 0,
|
||||
@@ -1300,10 +1302,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
fb: cx.opts.first_baseline,
|
||||
fb_min: cx.opts.first_baseline_min,
|
||||
queued: vec![],
|
||||
model: para.leading_model,
|
||||
model: t.para_at(0).leading_model,
|
||||
next_top: None,
|
||||
};
|
||||
'paras: for (pi, pr) in paras.iter().enumerate() {
|
||||
let para = t.para_at(pi);
|
||||
// The leading model is a paragraph attribute: this paragraph's lines (and the space to
|
||||
// its first line) follow its own.
|
||||
pen.model = para.leading_model;
|
||||
let text = cx.text;
|
||||
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
|
||||
let rtl = is_rtl(bidi.as_ref());
|
||||
@@ -1568,7 +1574,8 @@ fn path_steps(ap: &ArcPath, glyphs: &[SGlyph], from: f64, spacing: f64) -> Vec<f
|
||||
/// Lay type on a path out along `path` (text space), between its brackets.
|
||||
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &PathData) {
|
||||
cx.out.on_path = true;
|
||||
let para = &t.para;
|
||||
// Type on a path is one line: the first paragraph's attributes align it.
|
||||
let para = t.para_at(0);
|
||||
let mut sg = Vec::new();
|
||||
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
|
||||
let mut rtl = None;
|
||||
|
||||
@@ -209,6 +209,27 @@ fn indents_and_paragraph_spacing() {
|
||||
assert!((l.lines[1].baseline - l.lines[0].baseline - 18.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_paragraph_has_its_own_attributes() {
|
||||
let frame = Rect::new(0.0, 0.0, 200.0, 400.0);
|
||||
let mut t = area("left\ncentred\nright", style(10.0), frame, Justify::Left);
|
||||
let centred = vectorcraft_doc::ParaStyle { justify: Justify::Center, space_before: 20.0, ..Default::default() };
|
||||
let right = vectorcraft_doc::ParaStyle { justify: Justify::Right, left_indent: 7.0, ..Default::default() };
|
||||
t.set_paragraph_styles(vec![t.para.clone(), centred, right]);
|
||||
let l = layout(db(), &t);
|
||||
assert_eq!(l.lines.len(), 3);
|
||||
assert!(l.lines[0].x0.abs() < 1e-9, "the first paragraph stays left");
|
||||
let mid = (l.lines[1].x0 + l.lines[1].x1) / 2.0;
|
||||
assert!((mid - 100.0).abs() < 1e-6, "the second is centred: {mid}");
|
||||
assert!((l.lines[2].x1 - 200.0).abs() < 1e-6, "the third is right-aligned");
|
||||
// Space before applies to the second paragraph only (leading 12 + 20).
|
||||
assert!((l.lines[1].baseline - l.lines[0].baseline - 32.0).abs() < 1e-9);
|
||||
assert!((l.lines[2].baseline - l.lines[1].baseline - 12.0).abs() < 1e-9);
|
||||
// Paragraph 0's style is `para` (what readers that predate per-paragraph styles see).
|
||||
assert_eq!(t.paras.len(), 3);
|
||||
assert_eq!(t.para_at(0), &t.para);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_rect_frame_narrows_lines() {
|
||||
// Triangle pointing up: lines get wider towards the bottom.
|
||||
@@ -558,6 +579,46 @@ fn em_box_top_leading_hangs_lines_from_the_line_above() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The leading model is a paragraph attribute: in "大 / 小 / 大 / 小" (40 pt over 20 pt) with
|
||||
/// only the second paragraph top-to-top, its line hangs from the big line's em box (the big
|
||||
/// line's leading, below it) while the fourth, baseline to baseline, takes its own leading above
|
||||
/// it; in area type a top-to-top first paragraph puts its em box on the frame's top.
|
||||
#[test]
|
||||
fn leading_model_is_per_paragraph() {
|
||||
use vectorcraft_doc::{LeadingModel, ParaStyle, TextRun};
|
||||
let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) };
|
||||
let model = |m: LeadingModel| ParaStyle { leading_model: m, ..ParaStyle::default() };
|
||||
let top = |size: f64| 0.88 * size;
|
||||
let mut t = point("", st(40.0));
|
||||
t.runs = vec![
|
||||
TextRun { text: "大\n".into(), style: st(40.0) },
|
||||
TextRun { text: "小\n".into(), style: st(20.0) },
|
||||
TextRun { text: "大\n".into(), style: st(40.0) },
|
||||
TextRun { text: "小".into(), style: st(20.0) },
|
||||
];
|
||||
t.set_paragraph_styles(vec![
|
||||
model(LeadingModel::RomanBaseline),
|
||||
model(LeadingModel::EmBoxTop),
|
||||
model(LeadingModel::RomanBaseline),
|
||||
model(LeadingModel::RomanBaseline),
|
||||
]);
|
||||
assert_eq!(t.paras.len(), 4);
|
||||
let l = layout(db(), &t);
|
||||
let gap = |i: usize| l.lines[i].baseline - l.lines[i - 1].baseline;
|
||||
let em = -top(40.0) + 60.0 + top(20.0);
|
||||
assert!((gap(1) - em).abs() < 0.01, "top to top: {} vs {em}", gap(1));
|
||||
assert!((gap(3) - 30.0).abs() < 0.01, "baseline to baseline: {}", gap(3));
|
||||
// Area type: only a top-to-top first paragraph moves the first line up to the frame's top.
|
||||
let mut a = area("一\n二", st(20.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left);
|
||||
a.set_paragraph_styles(vec![model(LeadingModel::RomanBaseline), model(LeadingModel::EmBoxTop)]);
|
||||
let roman_first = layout(db(), &a).lines[0].baseline;
|
||||
a.set_paragraph_styles(vec![model(LeadingModel::EmBoxTop), model(LeadingModel::RomanBaseline)]);
|
||||
let em_first = layout(db(), &a);
|
||||
assert!((em_first.lines[0].baseline - top(20.0)).abs() < 0.01, "{}", em_first.lines[0].baseline);
|
||||
assert!((roman_first - em_first.lines[0].baseline).abs() > 0.5);
|
||||
assert!((em_first.lines[1].baseline - em_first.lines[0].baseline - 30.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Character Alignment: a 20 pt character next to a 40 pt one lines its em box top, centre or
|
||||
/// bottom up with the big one's (the em box from 0.12 em below the baseline to 0.88 em above it,
|
||||
/// in fonts without vertical metrics), or stays on the baseline; in vertical type the same across
|
||||
|
||||
@@ -312,3 +312,27 @@ fn visual_text_comes_back_in_logical_order() {
|
||||
assert_eq!(shown, visual);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph Direction is a paragraph attribute: in one text object a right-to-left paragraph
|
||||
/// reads and aligns (Auto) right to left while the next, left to right, starts on the left.
|
||||
#[test]
|
||||
fn paragraph_direction_is_per_paragraph() {
|
||||
use vectorcraft_doc::{Justify, ParaDirection, ParaStyle};
|
||||
let s = "Hello.\nHello.";
|
||||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||||
let dir = |direction| ParaStyle { justify: Justify::Auto, direction, ..ParaStyle::default() };
|
||||
t.set_paragraph_styles(vec![dir(Some(ParaDirection::RightToLeft)), dir(Some(ParaDirection::LeftToRight))]);
|
||||
assert_eq!(t.paras.len(), 2, "stored per paragraph");
|
||||
let l = layout(FontDb::global(), &t);
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
let shown = |line: &crate::LineInfo| -> String {
|
||||
let mut gs: Vec<_> = l.glyphs.iter().filter(|g| g.byte >= line.start && g.byte < line.end).collect();
|
||||
gs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
|
||||
gs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).filter(|c| *c != '\n').collect()
|
||||
};
|
||||
assert!(l.lines[0].rtl && !l.lines[1].rtl);
|
||||
assert_eq!(shown(&l.lines[0]), ".Hello", "the first paragraph runs right to left");
|
||||
assert_eq!(shown(&l.lines[1]), "Hello.", "the second runs left to right");
|
||||
assert!(l.lines[0].x1.abs() < 1e-6, "Auto aligns the right-to-left paragraph right: {}", l.lines[0].x1);
|
||||
assert!(l.lines[1].x0.abs() < 1e-6, "…and the left-to-right one left: {}", l.lines[1].x0);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//! testing need nothing special; after an edit the engine joins the slices and re-distributes them
|
||||
//! with [`distribute`].
|
||||
|
||||
use vectorcraft_doc::{TextObject, TextRun};
|
||||
use vectorcraft_doc::{ParaStyle, TextObject, TextRun};
|
||||
|
||||
use crate::edit::{normalize, runs_len, slice_runs, style_at};
|
||||
use crate::{FontDb, layout};
|
||||
@@ -16,6 +16,36 @@ pub fn story(frames: &[&TextObject]) -> Vec<TextRun> {
|
||||
runs
|
||||
}
|
||||
|
||||
/// The story's paragraph styles, one per story paragraph. A frame whose slice ends inside a
|
||||
/// paragraph shares that paragraph with the next frame (its first one): the earlier frame's style
|
||||
/// wins unless `later_wins[k]` says frame `k`'s first paragraph should (it was just restyled), or
|
||||
/// the earlier frame's part of the paragraph is empty (its slice ends with a paragraph break).
|
||||
pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec<ParaStyle> {
|
||||
let mut out: Vec<ParaStyle> = vec![];
|
||||
let mut prev_empty_tail = true;
|
||||
for (k, f) in frames.iter().enumerate() {
|
||||
let mut styles = f.paragraph_styles().into_iter();
|
||||
if k > 0
|
||||
&& let Some(first) = styles.next()
|
||||
&& (prev_empty_tail || later_wins.get(k).copied().unwrap_or(false))
|
||||
&& let Some(last) = out.last_mut()
|
||||
{
|
||||
*last = first;
|
||||
}
|
||||
out.extend(styles);
|
||||
let text = f.plain_text();
|
||||
prev_empty_tail = text.is_empty() || text.ends_with('\n') || out.is_empty();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Paragraph styles of the slice `runs` of a story starting at story paragraph `first`.
|
||||
fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec<ParaStyle> {
|
||||
let n = 1 + runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>();
|
||||
let fallback = paras.last().cloned().unwrap_or_default();
|
||||
(first..first + n).map(|i| paras.get(i).cloned().unwrap_or_else(|| fallback.clone())).collect()
|
||||
}
|
||||
|
||||
/// How many bytes of `runs` fit in `frame` (whole lines; a following break space or paragraph
|
||||
/// break stays with the line that ends there).
|
||||
fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
|
||||
@@ -37,21 +67,43 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
|
||||
end
|
||||
}
|
||||
|
||||
/// The story `runs` split across `frames` in order (the last frame takes any overflow). Empty
|
||||
/// slices keep one empty run carrying the style at the split, so typing there has a style.
|
||||
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec<Vec<TextRun>> {
|
||||
/// A frame's share of a story: its runs and its paragraphs' styles (one per paragraph).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Slice {
|
||||
pub runs: Vec<TextRun>,
|
||||
pub paras: Vec<ParaStyle>,
|
||||
}
|
||||
|
||||
/// The story `runs` (with paragraph styles `paras`, one per story paragraph; empty = each
|
||||
/// frame's own) split across `frames` in order (the last frame takes any overflow). Empty slices
|
||||
/// keep one empty run carrying the style at the split, so typing there has a style. A paragraph
|
||||
/// split between frames has its style in both.
|
||||
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec<Slice> {
|
||||
let mut rest = runs.to_vec();
|
||||
let mut out = Vec::with_capacity(frames.len());
|
||||
let mut next_para = 0;
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let first_para = next_para;
|
||||
let len = runs_len(&rest);
|
||||
let n = if i + 1 == frames.len() { len } else { fit(db, f, &rest) };
|
||||
let own = [f.para.clone()];
|
||||
let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) };
|
||||
let n = if i + 1 == frames.len() {
|
||||
len
|
||||
} else {
|
||||
let mut probe = (*f).clone();
|
||||
probe.runs = rest.clone();
|
||||
probe.set_paragraph_styles(slice_paras(paras, first_para, &rest));
|
||||
fit(db, &probe, &rest)
|
||||
};
|
||||
let mut head = slice_runs(&rest, 0, n);
|
||||
if head.is_empty() {
|
||||
head.push(TextRun { text: String::new(), style: style_at(&rest, n) });
|
||||
}
|
||||
let tail = slice_runs(&rest, n, len);
|
||||
rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len) }] } else { tail };
|
||||
out.push(head);
|
||||
let head_paras = slice_paras(paras, first_para, &head);
|
||||
next_para += head_paras.len().saturating_sub(1);
|
||||
out.push(Slice { runs: head, paras: head_paras });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -59,7 +111,7 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec<
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::{CharStyle, TextKind};
|
||||
use vectorcraft_doc::{CharStyle, Justify, TextKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
fn frame(x: f64) -> TextObject {
|
||||
@@ -75,7 +127,7 @@ mod tests {
|
||||
let (a, b, c) = (frame(0.0), frame(200.0), frame(400.0));
|
||||
let text = "The quick brown fox jumps over the lazy dog. ".repeat(6) + "\nSecond paragraph here.";
|
||||
let story = vec![TextRun { text: text.clone(), style: CharStyle::default() }];
|
||||
let parts = distribute(db, &[&a, &b, &c], &story);
|
||||
let parts: Vec<Vec<TextRun>> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect();
|
||||
let lens: Vec<usize> = parts.iter().map(|p| runs_len(p)).collect();
|
||||
assert!(lens[0] > 0 && lens[1] > 0, "{lens:?}");
|
||||
let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect();
|
||||
@@ -90,12 +142,43 @@ mod tests {
|
||||
assert!(!parts[1][0].text.starts_with(' '));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_styles_flow_with_their_paragraphs() {
|
||||
let db = FontDb::global();
|
||||
let (a, b) = (frame(0.0), frame(200.0));
|
||||
let text = "The quick brown fox jumps over the lazy dog. ".repeat(4) + "\nSecond paragraph.\nThird.";
|
||||
let story = vec![TextRun { text, style: CharStyle::default() }];
|
||||
let style = |j| ParaStyle { justify: j, ..Default::default() };
|
||||
use vectorcraft_doc::Justify::*;
|
||||
let paras = vec![style(Left), style(Center), style(Right)];
|
||||
let parts = distribute(db, &[&a, &b], &story, ¶s);
|
||||
assert!(!parts[0].runs.is_empty() && !parts[1].runs.is_empty());
|
||||
// The first paragraph overflows into the second frame: it is in both, with its style.
|
||||
let pa: Vec<Justify> = parts[0].paras.iter().map(|p| p.justify).collect();
|
||||
let pb: Vec<Justify> = parts[1].paras.iter().map(|p| p.justify).collect();
|
||||
assert_eq!(pa, [Left]);
|
||||
assert_eq!(pb, [Left, Center, Right]);
|
||||
// Joined again, the shared paragraph counts once.
|
||||
let mut fa = a.clone();
|
||||
fa.runs = parts[0].runs.clone();
|
||||
fa.set_paragraph_styles(parts[0].paras.clone());
|
||||
let mut fb = b.clone();
|
||||
fb.runs = parts[1].runs.clone();
|
||||
fb.set_paragraph_styles(parts[1].paras.clone());
|
||||
assert_eq!(story_paras(&[&fa, &fb], &[]), paras);
|
||||
// A frame whose shared first paragraph was restyled wins.
|
||||
fb.edit_paras(Some(0..1), |p| p.justify = JustifyAll);
|
||||
assert_eq!(story_paras(&[&fa, &fb], &[false, true])[0].justify, JustifyAll);
|
||||
assert_eq!(story_paras(&[&fa, &fb], &[false, false])[0].justify, Left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_story_leaves_later_frames_empty_with_a_style() {
|
||||
let db = FontDb::global();
|
||||
let (a, b) = (frame(0.0), frame(200.0));
|
||||
let st = CharStyle { size: 20.0, ..Default::default() };
|
||||
let parts = distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }]);
|
||||
let parts: Vec<Vec<TextRun>> =
|
||||
distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }], &[]).into_iter().map(|s| s.runs).collect();
|
||||
assert_eq!(parts[0][0].text, "Hi");
|
||||
assert_eq!((parts[1][0].text.as_str(), parts[1][0].style.size), ("", 20.0));
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ enum Mode {
|
||||
struct Typing {
|
||||
base: Vec<TextRun>,
|
||||
cur: Vec<TextRun>,
|
||||
/// The first byte any edit of the session touched (the text before it is unchanged): the
|
||||
/// previewed span starts no later, so Return at a paragraph's end splits that paragraph (its
|
||||
/// style continues), not the next one.
|
||||
lo: usize,
|
||||
}
|
||||
|
||||
/// IME marked text inside the typing session, not yet committed.
|
||||
@@ -174,9 +178,10 @@ impl TypeTool {
|
||||
if self.typing.is_none() {
|
||||
out.push(Action::Begin("Typing".into()));
|
||||
}
|
||||
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone() });
|
||||
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX });
|
||||
let len = edit::runs_len(&ty.cur);
|
||||
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
|
||||
ty.lo = ty.lo.min(a);
|
||||
let caret = match &styled {
|
||||
Some(r) => edit::replace_range_styled(&mut ty.cur, a, b, r),
|
||||
None => edit::replace_range(&mut ty.cur, a, b, insert),
|
||||
@@ -187,7 +192,7 @@ impl TypeTool {
|
||||
// One preview for the whole session: the changed span of base → cur, with its runs.
|
||||
let base: String = ty.base.iter().map(|r| r.text.as_str()).collect();
|
||||
let cur: String = ty.cur.iter().map(|r| r.text.as_str()).collect();
|
||||
let (p, s) = common_affixes(&base, &cur);
|
||||
let (p, s) = common_affixes(&base, &cur, ty.lo);
|
||||
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
|
||||
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
|
||||
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
|
||||
@@ -332,11 +337,12 @@ fn char_range_to_bytes(s: &str, r: Range<usize>) -> Option<Range<usize>> {
|
||||
(a <= b).then_some(a..b)
|
||||
}
|
||||
|
||||
/// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping).
|
||||
fn common_affixes(a: &str, b: &str) -> (usize, usize) {
|
||||
/// Lengths of the common prefix (at most `max_prefix`) and suffix of `a` and `b` (on char
|
||||
/// boundaries, not overlapping).
|
||||
fn common_affixes(a: &str, b: &str, max_prefix: usize) -> (usize, usize) {
|
||||
let mut p = 0;
|
||||
for ((i, x), y) in a.char_indices().zip(b.chars()) {
|
||||
if x != y {
|
||||
if x != y || i + x.len_utf8() > max_prefix {
|
||||
break;
|
||||
}
|
||||
p = i + x.len_utf8();
|
||||
|
||||
@@ -221,10 +221,13 @@ fn select_all_and_styled_paste() {
|
||||
|
||||
#[test]
|
||||
fn common_affixes_are_char_safe() {
|
||||
assert_eq!(common_affixes("hello", "help"), (3, 0));
|
||||
assert_eq!(common_affixes("aXb", "ab"), (1, 1));
|
||||
assert_eq!(common_affixes("aaa", "aaaa"), (3, 0));
|
||||
assert_eq!(common_affixes("é1", "è1"), (0, 1));
|
||||
assert_eq!(common_affixes("hello", "help", usize::MAX), (3, 0));
|
||||
assert_eq!(common_affixes("aXb", "ab", usize::MAX), (1, 1));
|
||||
assert_eq!(common_affixes("aaa", "aaaa", usize::MAX), (3, 0));
|
||||
assert_eq!(common_affixes("é1", "è1", usize::MAX), (0, 1));
|
||||
// Return at the end of a paragraph: the span starts where the edit did (before the old break).
|
||||
assert_eq!(common_affixes("abc\ndef", "abc\n\ndef", 3), (3, 4));
|
||||
assert_eq!(common_affixes("abc\ndef", "abc\n\ndef", 4), (4, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,16 +21,17 @@ pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.
|
||||
pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0];
|
||||
|
||||
/// The character style shown by the panel: the selected range's (or the caret's) while the Type
|
||||
/// tool edits text, else the first selected text object's first run.
|
||||
/// tool edits text, else the first selected text object's first run. With it, the paragraph
|
||||
/// attributes of the paragraph the selection starts in (else of the first paragraph).
|
||||
pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> {
|
||||
if let Some((id, a, b)) = text_editing(app)
|
||||
&& let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind)
|
||||
{
|
||||
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para.clone()));
|
||||
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone()));
|
||||
}
|
||||
let n = first_selected(app)?;
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => Some((t.first_style(), t.para.clone())),
|
||||
NodeKind::Text(t) => Some((t.first_style(), t.para_at(0).clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,15 +22,18 @@ pub const ALIGNMENTS: [(Justify, &str, &str, &str); 7] = [
|
||||
(Justify::JustifyAll, "dc-para-justify-all", "Justify all lines", "justifyAll"),
|
||||
];
|
||||
|
||||
/// Paragraph attributes apply to the whole text object (ending a Type tool typing session first).
|
||||
/// Paragraph attributes apply to the selected text objects' paragraphs or, while the Type tool
|
||||
/// edits text, to the paragraphs its selection (or caret) touches (ending its typing session).
|
||||
fn format(app: &mut VectorcraftApp, p: Value) {
|
||||
para_cmd(app, "text.setFormat", p);
|
||||
}
|
||||
|
||||
fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
|
||||
if let Some((id, _, _)) = super::character::text_editing(app) {
|
||||
if let Some((id, a, b)) = super::character::text_editing(app) {
|
||||
super::character::end_typing(app);
|
||||
p["ids"] = json!([id.0]);
|
||||
p["start"] = json!(a);
|
||||
p["end"] = json!(b);
|
||||
}
|
||||
app.run(cmd, p).ok();
|
||||
}
|
||||
|
||||
@@ -26,15 +26,21 @@ fn align_id(a: TabAlign) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first selected text object's stops and the ruler span in points (the frame width for area type).
|
||||
/// The stops of the caret's paragraph while the Type tool edits text (else the first selected text
|
||||
/// object's first paragraph) and the ruler span in points (the frame width for area type).
|
||||
fn current(app: &VectorcraftApp) -> Option<(Vec<TabStop>, f64)> {
|
||||
let n = first_selected(app)?;
|
||||
let editing = super::character::text_editing(app);
|
||||
let n = match editing {
|
||||
Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(),
|
||||
None => first_selected(app)?,
|
||||
};
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let para = editing.map_or(0, |(_, a, _)| t.paragraphs_in(a, a).start);
|
||||
let span = match &t.kind {
|
||||
vectorcraft_doc::TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()),
|
||||
_ => 360.0,
|
||||
};
|
||||
Some((t.para.tabs.clone(), span.max(72.0)))
|
||||
Some((t.para_at(para).tabs.clone(), span.max(72.0)))
|
||||
}
|
||||
|
||||
fn stops_json(stops: &[TabStop]) -> Value {
|
||||
@@ -47,9 +53,9 @@ fn stops_json(stops: &[TabStop]) -> Value {
|
||||
}
|
||||
|
||||
fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) {
|
||||
if let Some((id, _, _)) = super::character::text_editing(app) {
|
||||
if let Some((id, a, b)) = super::character::text_editing(app) {
|
||||
super::character::end_typing(app);
|
||||
app.run("text.tabs.set", json!({"stops": stops_json(stops), "ids": [id.0]})).ok();
|
||||
app.run("text.tabs.set", json!({"stops": stops_json(stops), "ids": [id.0], "start": a, "end": b})).ok();
|
||||
} else {
|
||||
app.run("text.tabs.set", json!({"stops": stops_json(stops)})).ok();
|
||||
}
|
||||
@@ -209,7 +215,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
};
|
||||
let sel: usize = pstate(ui.ctx(), "tabs-sel");
|
||||
if menu_item(ui, tl!("Clear All Tabs"), false, !stops.is_empty()) {
|
||||
app.run("text.tabs.clear", json!({})).ok();
|
||||
apply(app, &[]);
|
||||
}
|
||||
if menu_item(ui, tl!("Delete Tab"), false, sel < stops.len()) {
|
||||
stops.remove(sel);
|
||||
|
||||
@@ -67,11 +67,12 @@ fn current(app: &VectorcraftApp, kind: Kind) -> Option<(Option<String>, Map<Stri
|
||||
Some((name, m))
|
||||
}
|
||||
|
||||
/// Parameters targeting the selection: the Type tool's selected characters, or the selected objects.
|
||||
/// Parameters targeting the selection: the Type tool's selected characters (paragraph styles: the
|
||||
/// paragraphs its selection or caret touches), or the selected objects.
|
||||
fn target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value {
|
||||
if let Some((id, a, b)) = text_editing(app) {
|
||||
p["id"] = json!(id.0);
|
||||
if kind == Kind::Char && b > a {
|
||||
if kind == Kind::Para || b > a {
|
||||
p["start"] = json!(a);
|
||||
p["end"] = json!(b);
|
||||
}
|
||||
|
||||
+26
@@ -2306,6 +2306,32 @@ begins or ends, dragging the centre bracket slides the type along its path and,
|
||||
{"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}}
|
||||
```
|
||||
|
||||
## Paragraph attributes per paragraph
|
||||
|
||||
Each paragraph (text split at `\n`) has its own alignment, indents, space before and after, hyphenation,
|
||||
punctuation spacing (`mojikumi`), hanging punctuation (`burasagari`), direction, leading model, tab stops and
|
||||
paragraph style. `text.setStyle {justify}`, `text.setFormat {leftIndent, rightIndent, firstLineIndent,
|
||||
spaceBefore, spaceAfter, hyphenate, mojikumi, burasagari, direction, leadingModel}`, `text.tabs.set` / `text.tabs.clear` and `paraStyle.apply` take optional
|
||||
`start` / `end` byte offsets of the plain text (as `text.setRangeStyle` does): they change the paragraphs the range
|
||||
touches (a caret, `start == end`, touches its paragraph). Without a range they change every paragraph of the
|
||||
targeted objects, as before. With a range, the character attributes of `text.setStyle` and `text.setFormat` style
|
||||
that range of characters. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the
|
||||
paragraph at `start`; `paraStyle.list` counts paragraphs as uses. A Return the Type tool types (or `text.editRange`
|
||||
inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style;
|
||||
`text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as
|
||||
the story re-flows (a paragraph split between frames has them in both).
|
||||
|
||||
In the native file a text object's `para` holds the first paragraph's attributes and `paras` (left out when every
|
||||
paragraph is alike) one entry per paragraph, so older readers keep the first paragraph's. SVG export anchors each
|
||||
centred or right-aligned line on its own (`<tspan text-anchor>`), and SVG import gives each line's paragraph its
|
||||
alignment back, with indents that keep it in place.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.setStyle","params":{"id":42,"justify":"center","start":0,"end":0}}}
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"ids":[42],"spaceBefore":6,"start":12,"end":30}}}
|
||||
{"name":"run_command","arguments":{"command":"paraStyle.apply","params":{"name":"Heading","id":42,"start":0,"end":0}}}
|
||||
```
|
||||
|
||||
## Constrain proportions
|
||||
|
||||
The link between W and H in the Transform panel, the Properties panel and the Control bar is the
|
||||
|
||||
Reference in new issue
Block a user