mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge #600: M7: area type vertical alignment
This commit is contained in:
commit
c7b2bba90e
13 files changed
+524
-13
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), 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. 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), 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. 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), 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, Area Type Options (with Width/Height 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 | 79% | 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, 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 | 79% | 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 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 |
|
||||
|
||||
@@ -83,7 +83,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle,
|
||||
PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
|
||||
PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use vectorcraft_color as color;
|
||||
pub use vectorcraft_geom as geom;
|
||||
|
||||
+39
-1
@@ -532,6 +532,34 @@ pub enum FirstBaseline {
|
||||
Fixed,
|
||||
}
|
||||
|
||||
/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VerticalAlign {
|
||||
/// Lines start at the top of the cell (the default).
|
||||
#[default]
|
||||
Top,
|
||||
/// The block of lines is centred in the cell.
|
||||
Center,
|
||||
/// The last line's descent touches the cell bottom.
|
||||
Bottom,
|
||||
/// The first line stays at the top, the last one moves to the bottom and the space left over
|
||||
/// is shared equally between the lines (no paragraph spacing limit).
|
||||
Justify,
|
||||
}
|
||||
|
||||
impl VerticalAlign {
|
||||
pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
VerticalAlign::Top => "top",
|
||||
VerticalAlign::Center => "center",
|
||||
VerticalAlign::Bottom => "bottom",
|
||||
VerticalAlign::Justify => "justify",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text Wrap Options of a wrap object (Object → Text Wrap).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -634,11 +662,21 @@ pub struct AreaOptions {
|
||||
pub first_baseline: FirstBaseline,
|
||||
/// Minimum first-baseline offset in points.
|
||||
pub first_baseline_min: f64,
|
||||
/// Vertical alignment of the lines in each row/column.
|
||||
pub vertical_align: VerticalAlign,
|
||||
}
|
||||
|
||||
impl Default for AreaOptions {
|
||||
fn default() -> Self {
|
||||
Self { rows: 1, columns: 1, gutter: 18.0, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0 }
|
||||
Self {
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
gutter: 18.0,
|
||||
inset: 0.0,
|
||||
first_baseline: FirstBaseline::Ascent,
|
||||
first_baseline_min: 0.0,
|
||||
vertical_align: VerticalAlign::Top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area Type Options…",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options",
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options",
|
||||
has_selection,
|
||||
area_options
|
||||
),
|
||||
|
||||
@@ -339,3 +339,38 @@ fn the_type_widget_converts_point_and_area_type() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_options_align_the_lines_vertically() {
|
||||
use vectorcraft_doc::VerticalAlign;
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 200.0, 300.0, "Short story");
|
||||
sel(&mut s, &[id]);
|
||||
assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["verticalAlign"], "top");
|
||||
let top = layout(&s, id).lines[0].baseline;
|
||||
let before = text(&s, id).cached_bounds;
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("text.areaOptions", &json!({"verticalAlign": "bottom"})).unwrap();
|
||||
assert_eq!(r["verticalAlign"], "bottom");
|
||||
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Bottom);
|
||||
let l = layout(&s, id);
|
||||
let last = l.lines.last().unwrap();
|
||||
assert!((last.baseline + last.descent - 300.0).abs() < 1e-6);
|
||||
assert!(l.lines[0].baseline > top + 200.0);
|
||||
assert_ne!(text(&s, id).cached_bounds, before, "the cached bounds follow the text");
|
||||
assert_eq!((undo_len(&s), last_step(&s).as_str()), (n + 1, "Area Type Options"));
|
||||
s.execute("text.areaOptions", &json!({"verticalAlign": "center"})).unwrap();
|
||||
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Center);
|
||||
// Unknown values are refused and change nothing.
|
||||
for bad in [json!({"verticalAlign": "middle"}), json!({"verticalAlign": 3}), json!({"verticalAlign": null})] {
|
||||
assert!(s.execute("text.areaOptions", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Center);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Top);
|
||||
assert_eq!(layout(&s, id).lines[0].baseline, top);
|
||||
assert_eq!(text(&s, id).cached_bounds, before);
|
||||
assert!(cmd::find_command("text.areaOptions").unwrap().params.contains("verticalAlign?: top|center|bottom|justify"));
|
||||
}
|
||||
@@ -43,6 +43,31 @@ fn opts(version: u32, compress: bool) -> SaveOptions {
|
||||
SaveOptions { version, compress, ..SaveOptions::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_vertical_alignment_round_trips() {
|
||||
let d = rich_doc();
|
||||
let text = String::from_utf8(save(&d, false)).unwrap();
|
||||
assert!(text.contains("\"verticalAlign\":\"center\""), "the fixture's area type is centred");
|
||||
let back = load(text.as_bytes()).unwrap();
|
||||
assert_eq!(doc_json(&back), doc_json(&d));
|
||||
let area = |d: &Document| {
|
||||
let mut found = None;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })
|
||||
{
|
||||
found = Some(t.area.vertical_align);
|
||||
}
|
||||
});
|
||||
found.unwrap()
|
||||
};
|
||||
assert_eq!(area(&back), vectorcraft_doc::VerticalAlign::Center);
|
||||
// Files from before the option open top-aligned.
|
||||
let old = text.replacen("\"verticalAlign\":\"center\",", "", 1).replacen(",\"verticalAlign\":\"center\"", "", 1);
|
||||
assert!(!old.contains("verticalAlign"));
|
||||
assert_eq!(area(&load(old.as_bytes()).unwrap()), vectorcraft_doc::VerticalAlign::Top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_files_load() {
|
||||
let d = rich_doc();
|
||||
|
||||
@@ -68,6 +68,20 @@ fn wrapped_lines_get_one_tspan_each() {
|
||||
assert!(!export(&d, &ExportOptions::default()).contains("text-anchor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertically_aligned_area_type_exports_where_it_is_drawn() {
|
||||
let top = area_doc(vec![run("alpha beta", style(12.0))], 200.0, Justify::Left);
|
||||
let mut centred = top.clone();
|
||||
let id = centred.layers[0].children().unwrap()[0].id;
|
||||
if let Some(NodeKind::Text(t)) = centred.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.area.vertical_align = vectorcraft_doc::VerticalAlign::Center;
|
||||
}
|
||||
let y = |d: &Document| attr(tspans(&export(d, &ExportOptions::default()))[0], "y").unwrap();
|
||||
let (yt, yc) = (y(&top), y(¢red));
|
||||
// The 160 pt frame holds one 12 pt line: centring moves it down about (160 - 14.4) / 2.
|
||||
assert!(yc - yt > 60.0 && yc - yt < 80.0, "{yt} → {yc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_text_writes_spacing_shift_and_scale() {
|
||||
let shifted = CharStyle { baseline_shift: 4.0, ..style(12.0) };
|
||||
|
||||
@@ -115,6 +115,14 @@ pub fn rich_session() -> Session {
|
||||
let tx = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "VectorCraft", "size": 30})));
|
||||
// A non-default paragraph composer, so format round trips cover it.
|
||||
exec(&mut s, "text.setFormat", json!({"ids": [tx.0], "composer": "singleLine"}));
|
||||
// Area type with non-default Area Type Options (vertical alignment, inset).
|
||||
let area = id_of(&exec(
|
||||
&mut s,
|
||||
"text.create",
|
||||
json!({"x": 250, "y": 340, "text": "Centred area type", "size": 12, "area": {"width": 120, "height": 50}}),
|
||||
));
|
||||
select(&mut s, &[area]);
|
||||
exec(&mut s, "text.areaOptions", json!({"verticalAlign": "center", "inset": 2}));
|
||||
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);
|
||||
|
||||
+136
-6
@@ -13,7 +13,7 @@ use crate::composer::{Breakpoint, Params, compose};
|
||||
use crate::fontdb::FontDb;
|
||||
use crate::hyphen::hyphen_points;
|
||||
use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics};
|
||||
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout};
|
||||
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign};
|
||||
|
||||
const EPS: f64 = 1e-6;
|
||||
|
||||
@@ -127,6 +127,7 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
|
||||
inset: a.inset,
|
||||
first_baseline: a.first_baseline,
|
||||
first_baseline_min: a.first_baseline_min,
|
||||
vertical_align: a.vertical_align,
|
||||
..LayoutOptions::default()
|
||||
};
|
||||
layout_with(db, t, &opts)
|
||||
@@ -183,9 +184,12 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
} else {
|
||||
t.wrap.clone()
|
||||
};
|
||||
let regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
|
||||
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));
|
||||
if opts.vertical_align != VerticalAlign::Top {
|
||||
align_vertically(&mut cx, ¶s, &t.para, &mut regions);
|
||||
}
|
||||
}
|
||||
TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path),
|
||||
}
|
||||
@@ -416,6 +420,125 @@ fn finish_bounds(out: &mut TextLayout) {
|
||||
out.bounds = b.unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Space left in each cell after flowing top-aligned: (space above the first line's ascent, space
|
||||
/// below the last line's descent, number of distinct baselines). Cells without lines are `None`.
|
||||
fn cell_space(out: &TextLayout, regions: &[Region]) -> Vec<Option<(f64, f64, usize)>> {
|
||||
regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ri, r)| {
|
||||
let mut top = f64::INFINITY;
|
||||
let mut bottom = f64::NEG_INFINITY;
|
||||
let mut baselines: Vec<f64> = vec![];
|
||||
for l in out.lines.iter().filter(|l| l.region == ri) {
|
||||
top = top.min(l.baseline - l.ascent);
|
||||
bottom = bottom.max(l.baseline + l.descent);
|
||||
if !baselines.iter().any(|b| (b - l.baseline).abs() < 1e-6) {
|
||||
baselines.push(l.baseline);
|
||||
}
|
||||
}
|
||||
if baselines.is_empty() || !top.is_finite() || !bottom.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Never negative: a full (or overflowing) cell stays where top alignment put it.
|
||||
Some(((top - r.top()).max(0.0), (r.bottom() - bottom).max(0.0), baselines.len()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Area Type Options "Align" other than Top: move each cell's lines down. Rectangular cells
|
||||
/// without text wrap shift their lines (centre: half the space left below the last line;
|
||||
/// bottom: all of it; justify: the first line stays, each further line gets an equal share of it).
|
||||
/// 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]) {
|
||||
let align = cx.opts.vertical_align;
|
||||
let plain = regions.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty());
|
||||
if plain {
|
||||
let space = cell_space(&cx.out, regions);
|
||||
// Each distinct baseline's index within its cell, for justify.
|
||||
let mut seen: Vec<Vec<f64>> = vec![vec![]; regions.len()];
|
||||
let offs: Vec<f64> = cx
|
||||
.out
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let Some(Some((_, below, n))) = space.get(l.region).copied() else { return 0.0 };
|
||||
match align {
|
||||
VerticalAlign::Top => 0.0,
|
||||
VerticalAlign::Center => below * 0.5,
|
||||
VerticalAlign::Bottom => below,
|
||||
VerticalAlign::Justify => {
|
||||
let Some(s) = seen.get_mut(l.region) else { return 0.0 };
|
||||
let k = s.iter().position(|b| (b - l.baseline).abs() < 1e-6).unwrap_or_else(|| {
|
||||
s.push(l.baseline);
|
||||
s.len() - 1
|
||||
});
|
||||
if n > 1 { below * k as f64 / (n - 1) as f64 } else { 0.0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for (l, d) in cx.out.lines.iter_mut().zip(&offs) {
|
||||
l.baseline += d;
|
||||
}
|
||||
for g in &mut cx.out.glyphs {
|
||||
let d = offs.get(g.line).copied().unwrap_or(0.0);
|
||||
if d != 0.0 {
|
||||
let m = Affine::translate((0.0, d));
|
||||
g.outline.apply_affine(m);
|
||||
g.xf = m * g.xf;
|
||||
g.origin.y += d;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let laid_out = |out: &TextLayout| out.lines.last().map_or(0, |l| l.end);
|
||||
// How much of the measured space a pass takes up: halved after a pass that lost text.
|
||||
let mut step = 1.0;
|
||||
for _ in 0..8 {
|
||||
let space = cell_space(&cx.out, regions);
|
||||
let mut moved = false;
|
||||
let saved: Vec<(f64, f64)> = regions.iter().map(|r| (r.shift, r.gap)).collect();
|
||||
for (r, s) in regions.iter_mut().zip(&space) {
|
||||
let Some((above, below, n)) = *s else { continue };
|
||||
let (shift, gap) = match align {
|
||||
VerticalAlign::Top => (r.shift, r.gap),
|
||||
VerticalAlign::Center => (r.shift + (below - above) * 0.5 * step, r.gap),
|
||||
VerticalAlign::Bottom => (r.shift + below * step, r.gap),
|
||||
VerticalAlign::Justify if n > 1 => (r.shift, r.gap + below * step / (n - 1) as f64),
|
||||
VerticalAlign::Justify => (r.shift, r.gap),
|
||||
};
|
||||
let (shift, gap) = (shift.clamp(0.0, r.cell.height().max(0.0)), gap.clamp(0.0, r.cell.height().max(0.0)));
|
||||
if (shift - r.shift).abs() > 0.25 || (gap - r.gap).abs() > 0.01 {
|
||||
moved = true;
|
||||
}
|
||||
r.shift = shift;
|
||||
r.gap = gap;
|
||||
}
|
||||
if !moved {
|
||||
return;
|
||||
}
|
||||
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));
|
||||
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.
|
||||
(cx.out.glyphs, cx.out.lines, cx.out.overflow) = prev;
|
||||
for (r, (shift, gap)) in regions.iter_mut().zip(saved) {
|
||||
r.shift = shift;
|
||||
r.gap = gap;
|
||||
}
|
||||
step *= 0.5;
|
||||
if step < 0.1 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cell of a flattened area-type frame (the whole frame, or one row/column of it).
|
||||
struct Region {
|
||||
/// The cell (frame bounds, or a grid cell of them).
|
||||
@@ -427,6 +550,10 @@ struct Region {
|
||||
rect: bool,
|
||||
/// Text Wrap shapes: (polygons, offset, invert).
|
||||
wraps: Vec<(Vec<Vec<Point>>, f64, bool)>,
|
||||
/// Vertical alignment: how far below the top-aligned position the first line starts.
|
||||
shift: f64,
|
||||
/// Vertical justification: extra space added between consecutive lines.
|
||||
gap: f64,
|
||||
}
|
||||
|
||||
/// Flattened closed polygons of a path.
|
||||
@@ -529,7 +656,7 @@ impl Region {
|
||||
let x0 = bbox.x0 + c as f64 * (cw + gutter);
|
||||
let y0 = bbox.y0 + r as f64 * (rh + gutter);
|
||||
let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) };
|
||||
out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone() });
|
||||
out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone(), shift: 0.0, gap: 0.0 });
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -674,9 +801,9 @@ impl Pen<'_> {
|
||||
loop {
|
||||
let r = regions.get(self.ri)?;
|
||||
let mut baseline = match self.prev {
|
||||
None if self.model == LeadingModel::EmBoxTop => r.top() + est.top.max(self.fb_min),
|
||||
None => r.top() + est.first_baseline(self.fb, self.fb_min),
|
||||
Some(b) => self.next_baseline(b, est),
|
||||
None if self.model == LeadingModel::EmBoxTop => r.top() + r.shift + est.top.max(self.fb_min),
|
||||
None => r.top() + r.shift + est.first_baseline(self.fb, self.fb_min),
|
||||
Some(b) => self.next_baseline(b, est) + r.gap,
|
||||
};
|
||||
loop {
|
||||
if baseline + est.desc > r.bottom() + 0.01 {
|
||||
@@ -1205,6 +1332,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
glyph_start,
|
||||
glyph_end: cx.out.glyphs.len(),
|
||||
avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) },
|
||||
region: if regions.is_some() { pen.ri } else { 0 },
|
||||
});
|
||||
pen.settled(baseline, m);
|
||||
// Further spans of this line band share the settled baseline.
|
||||
@@ -1281,6 +1409,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
glyph_start: 0,
|
||||
glyph_end: 0,
|
||||
avail: (0.0, 0.0),
|
||||
region: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1365,6 +1494,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
glyph_start: 0,
|
||||
glyph_end: cx.out.glyphs.len(),
|
||||
avail: (0.0, ap.len()),
|
||||
region: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
pub use vectorcraft_doc::FirstBaseline;
|
||||
pub use vectorcraft_doc::{FirstBaseline, VerticalAlign};
|
||||
|
||||
/// Paragraph composer; stored per text object in [`vectorcraft_doc::ParaStyle::composer`].
|
||||
pub use vectorcraft_doc::Composer;
|
||||
@@ -50,6 +50,8 @@ pub struct LayoutOptions {
|
||||
pub first_baseline: FirstBaseline,
|
||||
/// Minimum first-baseline offset in points.
|
||||
pub first_baseline_min: f64,
|
||||
/// Vertical alignment of the lines in each row/column (Area Type Options "Align").
|
||||
pub vertical_align: VerticalAlign,
|
||||
/// Overrides the object's paragraph composer (`None` = use `ParaStyle::composer`).
|
||||
pub composer: Option<Composer>,
|
||||
pub features: OtFeatures,
|
||||
@@ -64,6 +66,7 @@ impl Default for LayoutOptions {
|
||||
inset: 0.0,
|
||||
first_baseline: FirstBaseline::Ascent,
|
||||
first_baseline_min: 0.0,
|
||||
vertical_align: VerticalAlign::Top,
|
||||
composer: None,
|
||||
features: OtFeatures::default(),
|
||||
}
|
||||
@@ -124,6 +127,8 @@ pub struct LineInfo {
|
||||
/// Horizontal span available to the line (frame span minus indents; the content extent for
|
||||
/// point type). Used for hit testing across columns.
|
||||
pub avail: (f64, f64),
|
||||
/// Area type: index of the frame cell ([`TextLayout::frames`]) the line sits in (0 otherwise).
|
||||
pub region: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
||||
@@ -457,6 +457,197 @@ fn rows_and_columns_flow_in_order() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- vertical alignment ----------
|
||||
|
||||
fn valign(a: VerticalAlign) -> LayoutOptions {
|
||||
LayoutOptions { vertical_align: a, ..Default::default() }
|
||||
}
|
||||
|
||||
/// (space above the first line's ascent, space below the last line's descent) in `0..h`.
|
||||
fn block_space(l: &TextLayout, h: f64) -> (f64, f64) {
|
||||
let (first, last) = (l.lines.first().unwrap(), l.lines.last().unwrap());
|
||||
(first.baseline - first.ascent, h - (last.baseline + last.descent))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_center_bottom_and_justify_in_a_rectangle() {
|
||||
let t = area("First line\nSecond line\nThird line", style(12.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left);
|
||||
let top = layout(db(), &t);
|
||||
let (above, below) = block_space(&top, 300.0);
|
||||
assert!(above.abs() < 1e-6 && below > 200.0);
|
||||
let center = layout_with(db(), &t, &valign(VerticalAlign::Center));
|
||||
let (a, b) = block_space(¢er, 300.0);
|
||||
assert!((a - b).abs() < 1e-6 && a > 100.0, "{a} {b}");
|
||||
// The model field reaches layout().
|
||||
let mut tc = t.clone();
|
||||
tc.area.vertical_align = VerticalAlign::Center;
|
||||
assert!((layout(db(), &tc).lines[0].baseline - center.lines[0].baseline).abs() < 1e-9);
|
||||
// Lines, glyph origins, outlines and transforms move together.
|
||||
let d = center.lines[0].baseline - top.lines[0].baseline;
|
||||
for (g, h) in center.glyphs.iter().zip(&top.glyphs) {
|
||||
assert!((g.origin.y - h.origin.y - d).abs() < 1e-9);
|
||||
assert!((g.xf.translation().y - h.xf.translation().y - d).abs() < 1e-9);
|
||||
if !h.outline.elements().is_empty() {
|
||||
assert!((g.outline.bounding_box().y0 - h.outline.bounding_box().y0 - d).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
assert!(center.bounds.y0 > top.bounds.y0 + 100.0);
|
||||
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
|
||||
let (a, b) = block_space(&bottom, 300.0);
|
||||
assert!(b.abs() < 1e-6 && a > 200.0);
|
||||
// Justify: the first line stays, the last one reaches the bottom, gaps are equal.
|
||||
let just = layout_with(db(), &t, &valign(VerticalAlign::Justify));
|
||||
let (a, b) = block_space(&just, 300.0);
|
||||
assert!(a.abs() < 1e-6 && b.abs() < 1e-6, "{a} {b}");
|
||||
let gaps: Vec<f64> = just.lines.windows(2).map(|w| w[1].baseline - w[0].baseline).collect();
|
||||
assert!(gaps.len() == 2 && (gaps[0] - gaps[1]).abs() < 1e-6 && gaps[0] > 100.0);
|
||||
// A single line justifies to the top.
|
||||
let one = area("Alone", style(12.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left);
|
||||
assert_eq!(layout_with(db(), &one, &valign(VerticalAlign::Justify)).lines[0].baseline, layout(db(), &one).lines[0].baseline);
|
||||
// Inset: the space is measured inside it.
|
||||
let inset = layout_with(db(), &t, &LayoutOptions { inset: 10.0, vertical_align: VerticalAlign::Bottom, ..Default::default() });
|
||||
assert!(block_space(&inset, 290.0).1.abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_leaves_overflowing_text_where_it_is() {
|
||||
let text = COPY.repeat(4);
|
||||
let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left);
|
||||
let top = layout(db(), &t);
|
||||
assert!(top.overflow);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
let l = layout_with(db(), &t, &valign(a));
|
||||
assert!(l.overflow);
|
||||
assert_eq!(l.lines.len(), top.lines.len());
|
||||
assert_eq!(l.lines.last().unwrap().end, top.lines.last().unwrap().end);
|
||||
// A full cell has less than a line of space left: the lines barely move.
|
||||
assert!(l.lines[0].baseline - top.lines[0].baseline < top.lines[0].ascent + top.lines[0].descent);
|
||||
assert!(block_space(&l, 100.0).1 >= -1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_acts_on_each_column_on_its_own() {
|
||||
// Enough text for column 1 and a few lines of column 2.
|
||||
let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 420.0, 120.0), Justify::Left);
|
||||
let opts = |a| LayoutOptions { columns: 2, gutter: 20.0, vertical_align: a, ..Default::default() };
|
||||
let top = layout_with(db(), &t, &opts(VerticalAlign::Top));
|
||||
let col = |l: &TextLayout, c: usize| l.lines.iter().filter(|li| li.region == c).cloned().collect::<Vec<_>>();
|
||||
assert!(!top.overflow && !col(&top, 0).is_empty() && !col(&top, 1).is_empty());
|
||||
assert!(col(&top, 1).iter().all(|li| li.x0 >= 220.0 - 1e-6));
|
||||
let bottom = layout_with(db(), &t, &opts(VerticalAlign::Bottom));
|
||||
for c in 0..2 {
|
||||
let last = col(&bottom, c).last().cloned().unwrap();
|
||||
assert!((last.baseline + last.descent - 120.0).abs() < 1e-6, "column {c} ends at the bottom");
|
||||
}
|
||||
let shift = |c: usize| col(&bottom, c)[0].baseline - col(&top, c)[0].baseline;
|
||||
assert!(shift(1) > shift(0) + 20.0, "the short column moves further: {} {}", shift(0), shift(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_in_a_circle_reflows_and_stays_inside() {
|
||||
let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1);
|
||||
let t = area_path("Centred text in a round frame flows again at its new height.", style(12.0), &circle, Justify::Left);
|
||||
let top = layout(db(), &t);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
let l = layout_with(db(), &t, &valign(a));
|
||||
assert!(!l.overflow, "{a:?}");
|
||||
assert_eq!(l.lines.last().unwrap().end, top.lines.last().unwrap().end);
|
||||
let moved = if a == VerticalAlign::Justify {
|
||||
l.lines.last().unwrap().baseline - top.lines.last().unwrap().baseline
|
||||
} else {
|
||||
l.lines[0].baseline - top.lines[0].baseline
|
||||
};
|
||||
assert!(moved > 20.0, "{a:?} {moved}");
|
||||
for g in l.glyphs.iter().filter(|g| !g.outline.elements().is_empty()) {
|
||||
let b = g.outline.bounding_box();
|
||||
for p in [Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)] {
|
||||
assert!((p - Point::new(150.0, 150.0)).hypot() <= 121.0, "{a:?} {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = layout_with(db(), &t, &valign(VerticalAlign::Center));
|
||||
let (above, below) = block_space(&c, 270.0);
|
||||
assert!((above - 30.0 - below).abs() < 10.0, "roughly centred: {above} {below}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_with_text_wrap_flows_again_around_the_object() {
|
||||
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 300.0, 400.0), Justify::Left);
|
||||
let obstacle = Rect::new(100.0, 250.0, 200.0, 330.0).to_path(0.1);
|
||||
t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&obstacle), wrap: Default::default() }];
|
||||
let top = layout(db(), &t);
|
||||
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
|
||||
assert!(!bottom.overflow);
|
||||
assert_eq!(bottom.lines.last().unwrap().end, top.lines.last().unwrap().end);
|
||||
let last = bottom.lines.last().unwrap();
|
||||
assert!(400.0 - (last.baseline + last.descent) < 15.0, "near the bottom: {}", last.baseline);
|
||||
// Lines beside the object leave room for it (offset 6 pt).
|
||||
for li in bottom.lines.iter().filter(|li| li.baseline + li.descent > 244.0 && li.baseline - li.ascent < 336.0) {
|
||||
assert!(li.x1 <= 94.0 + 1e-6 || li.x0 >= 206.0 - 1e-6, "{li:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_on_vertical_type_moves_columns_along_the_block_axis() {
|
||||
// Vertical type: the block axis runs right to left, so "bottom" is the frame's left edge.
|
||||
let mut t = area("Tate\nYoko", style(20.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left);
|
||||
t.vertical = true;
|
||||
let top = layout(db(), &t);
|
||||
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
|
||||
let center = layout_with(db(), &t, &valign(VerticalAlign::Center));
|
||||
assert!(top.bounds.x1 > 190.0 && top.bounds.x0 > 100.0, "{:?}", top.bounds);
|
||||
assert!(bottom.bounds.x0.abs() < 0.5, "{:?}", bottom.bounds);
|
||||
assert!((center.bounds.x0 - (200.0 - center.bounds.x1)).abs() < 0.5, "{:?}", center.bounds);
|
||||
// Glyphs stay inside the frame; lines keep line-space coordinates.
|
||||
for g in &bottom.glyphs {
|
||||
assert!(g.origin.x >= -1e-6 && g.origin.x <= 200.0 + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-to-Top leading (em box top to em box top): the first line hangs from the frame's top and a
|
||||
/// line's leading is the space below it. Vertical alignment measures the space from the lines that
|
||||
/// model placed, keeps their spacing when it shifts them, and justify adds its share on top of it.
|
||||
#[test]
|
||||
fn vertical_align_with_top_to_top_leading() {
|
||||
use vectorcraft_doc::LeadingModel;
|
||||
let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) };
|
||||
let mut t = area("", st(40.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left);
|
||||
t.runs = vec![TextRun { text: "大\n".into(), style: st(40.0) }, TextRun { text: "小\n中".into(), style: st(20.0) }];
|
||||
t.para.leading_model = LeadingModel::EmBoxTop;
|
||||
let top = layout(db(), &t);
|
||||
assert_eq!(top.lines.len(), 3);
|
||||
assert!((top.lines[0].baseline - 0.88 * 40.0).abs() < 0.01, "first em box at the top: {}", top.lines[0].baseline);
|
||||
let gaps = |l: &TextLayout| l.lines.windows(2).map(|w| w[1].baseline - w[0].baseline).collect::<Vec<_>>();
|
||||
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
|
||||
assert!(block_space(&bottom, 300.0).1.abs() < 1e-6, "{:?}", block_space(&bottom, 300.0));
|
||||
for (a, b) in gaps(&bottom).iter().zip(gaps(&top)) {
|
||||
assert!((a - b).abs() < 1e-6, "spacing kept: {a} {b}");
|
||||
}
|
||||
let center = layout_with(db(), &t, &valign(VerticalAlign::Center));
|
||||
let d = center.lines[0].baseline - top.lines[0].baseline;
|
||||
assert!((d - block_space(&top, 300.0).1 * 0.5).abs() < 1e-6, "{d}");
|
||||
let just = layout_with(db(), &t, &valign(VerticalAlign::Justify));
|
||||
assert!((just.lines[0].baseline - top.lines[0].baseline).abs() < 1e-9, "the first line stays");
|
||||
assert!(block_space(&just, 300.0).1.abs() < 1e-6);
|
||||
let extra: Vec<f64> = gaps(&just).iter().zip(gaps(&top)).map(|(a, b)| a - b).collect();
|
||||
assert!(extra.len() == 2 && (extra[0] - extra[1]).abs() < 1e-6 && extra[0] > 50.0, "{extra:?}");
|
||||
// A round frame reflows with Top-to-Top leading too, without losing text.
|
||||
let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1);
|
||||
let mut round = area_path("", st(20.0), &circle, Justify::Left);
|
||||
round.runs = t.runs.clone();
|
||||
round.para.leading_model = LeadingModel::EmBoxTop;
|
||||
let rtop = layout(db(), &round);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
let l = layout_with(db(), &round, &valign(a));
|
||||
assert!(!l.overflow, "{a:?}");
|
||||
assert_eq!(l.lines.last().unwrap().end, rtop.lines.last().unwrap().end);
|
||||
// The 40 pt line starts low in the circle already, so centring moves it a little.
|
||||
let want = if a == VerticalAlign::Center { 1.0 } else { 20.0 };
|
||||
assert!(l.lines.last().unwrap().baseline > rtop.lines.last().unwrap().baseline + want, "{a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- OpenType ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,6 +38,8 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
||||
match (command, key) {
|
||||
("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS),
|
||||
("type.pathOptions", "alignToPath") => Some(PATH_ALIGN),
|
||||
("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE),
|
||||
("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -45,6 +47,13 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
||||
/// Type on a Path Options › Align to Path.
|
||||
const PATH_ALIGN: form::Choices = &[("Ascender", "ascender"), ("Descender", "descender"), ("Center", "center"), ("Baseline", "baseline")];
|
||||
|
||||
/// Area Type Options › First Baseline.
|
||||
const FIRST_BASELINE: form::Choices =
|
||||
&[("Ascent", "ascent"), ("Cap Height", "capHeight"), ("x Height", "xHeight"), ("Leading", "leading"), ("Fixed", "fixed")];
|
||||
|
||||
/// Area Type Options › Align (vertical alignment of the lines in each row/column).
|
||||
const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")];
|
||||
|
||||
/// Closes before running, so a dialog the command opens stays open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
@@ -59,3 +68,43 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
}
|
||||
app.run(&cmd, params)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeKind, VerticalAlign};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn area_type_options_choices_match_the_params_doc() {
|
||||
let doc = vectorcraft_engine::find_command("text.areaOptions").unwrap().params;
|
||||
for key in ["firstBaseline", "verticalAlign"] {
|
||||
let values: Vec<&str> = choices("text.areaOptions", key).unwrap().iter().map(|(_, v)| *v).collect();
|
||||
assert!(doc.contains(&format!("{key}?: {}", values.join("|"))), "{key}: {values:?}");
|
||||
}
|
||||
assert!(choices("text.areaOptions", "firstBaselineMin").is_none(), "a distance, not a choice");
|
||||
assert!(choices("text.areaOptions", "inset").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_options_dialog_sets_the_vertical_alignment() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 20, "text": "Hello", "area": {"width": 200, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
crate::menus::invoke(&mut app, "text.areaOptions", json!({}));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("verticalAlign"), "top");
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("verticalAlign".into(), json!("center"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let NodeKind::Text(t) = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.area.vertical_align, VerticalAlign::Center);
|
||||
}
|
||||
}
|
||||
+16
@@ -2201,6 +2201,22 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane
|
||||
{"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}}
|
||||
```
|
||||
|
||||
### Vertical alignment
|
||||
|
||||
`text.areaOptions {verticalAlign: top|center|bottom|justify}` (Area Type Options › Align) places the lines of each
|
||||
row/column cell on its own: `center` centres the block of lines in the cell, `bottom` puts the last line's descent on
|
||||
the cell's bottom (inside the inset), `justify` keeps the first line at the top, moves the last one to the bottom and
|
||||
shares the space left over equally between the lines (there is no paragraph spacing limit: every line gap grows by
|
||||
the same amount). A cell with a single line justifies to the top, and a full or overflowing cell only moves by the
|
||||
less-than-a-line of space it has left. In rectangular frames without text wrap the lines just move; in other
|
||||
frames (and around wrap objects) the text flows again starting lower until it settles, never losing text that fit
|
||||
top-aligned. Vertical type aligns along its block axis (`bottom` is the frame's left edge). Exports lay type out
|
||||
again, so SVG, PDF and EPS show the aligned text.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.areaOptions","params":{"verticalAlign":"center"}}}
|
||||
```
|
||||
|
||||
## Converting between point type and area type
|
||||
|
||||
With the Selection tool, a single selected point or area type object shows the type widget: a small circle beside
|
||||
|
||||
Reference in new issue
Block a user