Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 72c9cec0e0 Fix #847: draw Underline and Strikethrough
The styles were stored on the text and written to SVG's text-decoration,
but the type was drawn without the lines. vectorcraft_text::decorations
now gives each underlined or struck-through stretch of a run on a line
its bar, placed and sized from the font's own underline and strikeout
metrics (with fallbacks for fonts that have none). The canvas, Create
Outlines, PDF export (outlined and real text) and SVG's outlined text
draw them in the run's paint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:03:04 +07:00
BFlat c93d4ad995 Merge pull request #810 from Messhias/feat/394-hyphenation-exceptions
Part of #394: Hyphenation › Exceptions reach the hyphenator
2026-10-10 05:46:03 +07:00
BFlat 4cf912fa94 Merge pull request #820 from storytold/fix/812-ellipse-pie
Part of #812: an ellipse's Pie Start and End Angle and Invert Pie
2026-10-10 03:00:32 +07:00
bflatasticandClaude Opus 5.5 78f0297127 Part of #812: an ellipse's Pie Start and End Angle and Invert Pie
An ellipse kept pie angles in its model, but nothing drew or set them. Now
the Transform and Properties panels show Ellipse Properties with Pie Start
Angle, Pie End Angle and Invert Pie, as in Illustrator, and
object.setLiveShape takes {pieStart, pieEnd, invertPie}. The pie runs
counterclockwise on screen from the start angle (0 at 3 o'clock) to the
end angle and closes through the centre; 0 to 360 is the whole ellipse,
an end of 0 means 360, and Invert Pie swaps the angles to show the other
part. One undo step each; a bad angle is an error. The labels are
translated in every complete language. The on-canvas pie widget is still
to come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:00:15 +07:00
BFlat d857d2c0b7 Merge pull request #814 from jmverges/font-versions
Fix #813: type can name an installed version of its font
2026-10-10 02:44:09 +07:00
BFlat 9df597a46d Merge pull request #816 from storytold/fix/807-all-tools-drawer
Fix #807: All Tools shows every tool with its icon, under headings
2026-10-10 02:33:18 +07:00
BFlat 4c175ed099 Merge pull request #815 from storytold/fix/808-slim-toolbar-scrollbar
Fix #808: the toolbar's scroll bar stays a slim line
2026-10-10 02:33:10 +07:00
bflatasticandClaude Opus 5.5 986b83280f Fix #808: the toolbar's scroll bar stays a slim line
The toolbar and the dock's icon column scroll when the window is too
short for them. Their scroll bar was egui's floating one, which widens to
10 pt while scrolled or dragged and covered the group labels and the
icons' right edge. It now stays at most 4 pt wide (2 pt at rest), at the
column's edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:32:27 +07:00
Fabio William ConceiçãoandCursor 445bceb681 Part of #394: look up hyphenation exceptions under the lock; drop ~ syntax.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 20:26:56 +01:00
Fabio William ConceiçãoandCursor 4f9b292213 Part of #394: headless tests for Hyphenation › Exceptions UI and layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 20:26:56 +01:00
Fabio William Conceição c9ba5db4fb Part of #394: Preferences › Hyphenation › Exceptions reach the hyphenator.
Comma-separated words never break, or break only at marked hyphens; prefs.set and restore push the list so type reflows.
2026-10-09 20:26:56 +01:00
Juan Manuel Verges SolanasandClaude Opus 5.5 562e09707a Fix #813: type can name an installed version of its font
A font library can hold two versions of one family and style (a new
Montserrat-Bold.ttf beside an old montserrat.ttf). The font database kept
only the first one loaded, so type set in the other couldn't be matched
when a PDF or .ai was opened: it came in as outlines. Now every version
loads (the first stays the one family and style resolve to), PDF import
tries the other versions when the glyphs differ, and type set in a version
the family and style alone wouldn't pick names it in CharStyle::font_version
(left out when None, so documents without it are unchanged). Layout,
shaping, the substituted-glyph highlight, Find Font, Package and Document
Info follow it; changing the font clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:26:51 +00:00
BFlat b43e633329 Merge pull request #754 from hamada147/feature/support-arabic-language
feat(text): load craft-fonts' Arabic families, fetch them on web
2026-10-10 02:08:29 +07:00
bflatastic 8c1be41e08 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	ROADMAP.md
2026-10-10 01:56:46 +07:00
bflatasticandClaude Opus 5.5 9de418a4a4 ROADMAP: the Arabic families come with a craft-fonts checkout that has them
craft-fonts main holds only Noto Sans Arabic so far (the other eleven are in
storytold/craft-fonts#11), and releases pin an older craft-fonts commit, so
say which builds list them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:16:41 +07:00
Ahmed Moussa 806ba127e7 Merge branch 'main' into feature/support-arabic-language 2026-10-09 17:19:05 +03:00
Ahmed Moussa 3e20c2cf29 build(web): copy web fonts with cargo xtask, not sh
Replace the Trunk post_build hook packaging/web/copy-fonts.sh with `cargo xtask web-fonts` (xtask/src/web_fonts.rs), so the web build only needs trunk, the wasm32 target and cargo, with no POSIX shell, and works the same on Windows.

The new task reuses crates/text/build/web_fonts.rs for list parsing and URLs, so build.rs and the copier can't disagree on fonts/<sha16>/<file>. It still does nothing without CRAFT_FONTS_DIR, checks each file's SHA-256 against the manifest, and copies the licence next to each face. A listed face that an older craft-fonts checkout lacks is now skipped with a warning, as build.rs does, instead of failing the build. A relative CRAFT_FONTS_DIR is resolved from the workspace root.

Update the references in Trunk.toml, fonts.rs, build.rs, craft_fonts.rs and web-fonts.txt, and document the web build's requirements in docs/development.md. Add tests for the copy, a hash mismatch and the no-op case.

Signed-off-by: Ahmed Moussa <moussa.ahmed95@gmail.com>
2026-10-09 17:18:54 +03:00
Ahmed Moussa 3c85f3f9e3 feat(text): load craft-fonts' Arabic families, fetch them on web
Desktop builds made with CRAFT_FONTS_DIR now load craft-fonts' 12 Arabic families into the document font database. Noto Sans Arabic comes first and is the fallback for Arabic text. Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar can be picked by name from the font menus.

The web build doesn't embed these fonts because they would make the wasm too large. It fetches them from beside the wasm instead:
- crates/text/web-fonts.txt lists each face as startup or background.
- build.rs turns that list into vectorcraft_text::WEB_FONTS, with each face's URL (fonts/<sha16>/<file>) and SRI hash taken from the craft-fonts manifest.
- packaging/web/copy-fonts.sh, a Trunk post_build hook, copies the font files and their OFL licences there.
- apps/vectorcraft-web/src/fonts.rs fetches Noto Sans Arabic before the app starts, so Arabic text never shows as boxes. It loads the other faces in the background and logs `web font …` for any that fail.

Because fonts can now arrive after startup, the Type tool's layout cache also checks the font database's generation, so text lays out again when new fonts arrive.

_headers and .htaccess serve fonts/* as font/ttf and cache it as immutable. packaging/web/README.md and docs/development.md explain the fonts folder, and ROADMAP.md lists the Arabic typefaces under Type. Also adds Ahmed Moussa to contributors/people.toml.

Signed-off-by: Ahmed Moussa <moussa.ahmed95@gmail.com>
2026-10-09 16:09:34 +03:00
62 changed files with 1462 additions and 75 deletions

No files matched your search

Generated
+1
View File
@@ -4769,6 +4769,7 @@ dependencies = [
"rfd",
"vectorcraft-engine",
"vectorcraft-format",
"vectorcraft-text",
"vectorcraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
+5 -5
View File
@@ -114,7 +114,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). About 20 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). About 19 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
@@ -129,7 +129,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
@@ -147,7 +147,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), 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. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. 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 › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724).
- **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. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. 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. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). 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 › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
- **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), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Japanese, Russian and Ukrainian 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, French, Italian, Russian and Ukrainian 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 applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), 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 line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), 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, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; 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.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
@@ -163,7 +163,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579).
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579).
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
@@ -232,7 +232,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 20 Preferences options stored but not yet acted on (#394: Spacing Guides, Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation, Appearance of Black, devices…) | 14–22 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 19 Preferences options stored but not yet acted on (#394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 80% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles are done (#745); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
+2 -1
View File
@@ -17,6 +17,7 @@ path = "src/main.rs"
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-format = { workspace = true }
vectorcraft-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -27,4 +28,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+8
View File
@@ -8,3 +8,11 @@ filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8766
# Copy the craft-fonts faces web builds fetch (crates/text/web-fonts.txt) into the site's fonts/
# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`, through cargo so
# it needs no shell (xtask/src/web_fonts.rs).
[[hooks]]
stage = "post_build"
command = "cargo"
command_arguments = ["xtask", "web-fonts"]
+109
View File
@@ -0,0 +1,109 @@
//! Craft-fonts faces served beside the wasm (`fonts/<sha16>/…`, copied by
//! `cargo xtask web-fonts`) instead of embedded: [`load_startup_fonts`] fetches the `startup`
//! ones before the app starts, [`spawn_background_fonts`] the rest once it runs. The browser checks
//! each file against its SHA-256 (Subresource Integrity). A face that fails is logged and skipped:
//! the app runs without it. With `WEB_FONTS` empty (built without craft-fonts) both do nothing.
use std::cell::Cell;
use std::rc::Rc;
use vectorcraft_text::{FontDb, WEB_FONTS, WebFont};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::closure::Closure;
use wasm_bindgen_futures::JsFuture;
/// Per startup face: the page shows its loading text meanwhile.
const STARTUP_TIMEOUT_MS: i32 = 20_000;
/// Per background face.
const BACKGROUND_TIMEOUT_MS: i32 = 120_000;
/// Background fetches at once, leaving the connection to the app's own requests.
const BACKGROUND_PARALLEL: usize = 4;
thread_local! {
/// The background fetch runs once per page, though a graphics restart creates the app again.
static BACKGROUND_STARTED: Cell<bool> = const { Cell::new(false) };
}
/// Fetch and register the `startup` faces (one after the other). Returns how many loaded.
pub async fn load_startup_fonts() -> usize {
let mut loaded = 0;
for f in WEB_FONTS.iter().filter(|f| f.startup) {
if load(f, STARTUP_TIMEOUT_MS).await {
loaded += 1;
}
}
loaded
}
/// Fetch and register the `background` faces, [`BACKGROUND_PARALLEL`] at a time, asking `ctx` for
/// a frame after each so the font menus and the canvas pick it up. Only the first call does
/// anything.
pub fn spawn_background_fonts(ctx: egui::Context) {
if BACKGROUND_STARTED.with(|s| s.replace(true)) {
return;
}
let queue: Rc<Vec<&'static WebFont>> = Rc::new(WEB_FONTS.iter().filter(|f| !f.startup).collect());
let next = Rc::new(Cell::new(0usize));
for _ in 0..BACKGROUND_PARALLEL.min(queue.len()) {
let (queue, next, ctx) = (queue.clone(), next.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
loop {
let i = next.get();
next.set(i + 1);
let Some(f) = queue.get(i) else { break };
if load(f, BACKGROUND_TIMEOUT_MS).await {
ctx.request_repaint();
}
}
});
}
}
/// Fetch one face and add it to the font database. False (and a console warning) on failure.
async fn load(f: &WebFont, timeout_ms: i32) -> bool {
let bytes = match fetch(f.url, f.integrity, timeout_ms).await {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("web font {} {}: {e}", f.family, f.style);
return false;
}
};
if FontDb::global().add_font(bytes) == 0 {
log::warn!("web font {} {}: not a font, or already loaded", f.family, f.style);
return false;
}
true
}
/// GET `url` (relative to the page) with Subresource Integrity `integrity`, aborted after
/// `timeout_ms`.
async fn fetch(url: &str, integrity: &str, timeout_ms: i32) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
let abort = web_sys::AbortController::new().map_err(js)?;
let init = web_sys::RequestInit::new();
init.set_method("GET");
init.set_integrity(integrity);
init.set_signal(Some(&abort.signal()));
let on_timeout = {
let abort = abort.clone();
Closure::<dyn FnMut()>::new(move || abort.abort())
};
let timer = window.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), timeout_ms).map_err(js)?;
let result = async {
let response: web_sys::Response = JsFuture::from(window.fetch_with_str_and_init(url, &init)).await.map_err(js)?.dyn_into().map_err(js)?;
if !response.ok() {
return Err(format!("HTTP {} for {url}", response.status()));
}
let buffer = JsFuture::from(response.array_buffer().map_err(js)?).await.map_err(js)?;
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
}
.await;
window.clear_timeout_with_handle(timer);
// Kept alive until the timer can no longer fire.
drop(on_timeout);
result
}
fn js(e: wasm_bindgen::JsValue) -> String {
format!("{e:?}")
}
+2
View File
@@ -14,6 +14,8 @@
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
#[cfg(target_arch = "wasm32")]
mod fonts;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(target_arch = "wasm32")]
+4
View File
@@ -64,6 +64,9 @@ pub fn start() {
wasm_bindgen_futures::spawn_local(async move {
// Before the first frame, which looks for copies a crash left behind.
let locks = WebLocks::start(RECOVERY_PREFIX).await;
// Fallback fonts the first frame needs (Noto Sans Arabic), fetched while the loading text
// shows; the rest load in the background once the app runs.
crate::fonts::load_startup_fonts().await;
let host = Host {
runner: eframe::WebRunner::new(),
inbox: Arc::default(),
@@ -99,6 +102,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
web_options(),
Box::new(move |cc| {
let ctx = &cc.egui_ctx;
crate::fonts::spawn_background_fonts(ctx.clone());
let loss = GraphicsLoss::default();
watch_canvas(&page, &loss, ctx);
if let Some(rs) = &cc.wgpu_render_state {
+5
View File
@@ -40,3 +40,8 @@ sync_github_name = true
real_name = "Hal Goff"
display_name = "Hal Goff, HG Studio"
sync_github_name = false
[people.hamada147]
real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = true
+1 -1
View File
@@ -146,7 +146,7 @@ impl LiveShape {
LiveShape::Rectangle { w, h, radii, kinds, xf } => {
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
}
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf),
LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0,
LiveShape::Line { a, b } => shapes::line(*a, *b),
LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0,
+6
View File
@@ -27,6 +27,11 @@ pub struct CharStyle {
pub font_family: String,
#[serde(default = "regular")]
pub font_style: String,
/// Which installed version of the font (its version string) when several versions of the
/// family and style are installed and the text was set in one the family and style alone
/// wouldn't pick (an imported file's type matched to it); None = the one they pick.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_version: Option<String>,
/// Size in points.
pub size: f64,
/// Leading in points; None = Auto (120% of size).
@@ -175,6 +180,7 @@ impl Default for CharStyle {
Self {
font_family: "Source Sans 3".into(),
font_style: "Regular".into(),
font_version: None,
size: 12.0,
leading: None,
tracking: 0.0,
+12 -8
View File
@@ -97,7 +97,7 @@ type ImageRow = (String, u32, u32, Option<String>);
/// The Document Info categories of `d` (only `category` when given) from `info` (what
/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns.
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
let names = |k: &str| -> Vec<(String, String)> {
info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect()
};
@@ -135,14 +135,18 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag
"patterns" => names("patternNames"),
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
"symbols" => names("symbols"),
"fonts" => fonts.iter().map(|(f, s)| (format!("{f} {s}"), String::new())).collect(),
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
"fontDetails" => {
let db = vectorcraft_text::FontDb::global();
fonts
.iter()
.map(|(family, style)| {
// Found by any of its names; a style the family lacks shows in another.
let detail = match db.resolve(family, style) {
.map(|used| {
let (family, style, version) = used;
// Found by any of its names (in the version the type names); a style the
// family lacks shows in another.
let resolved =
db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m));
let detail = match resolved {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
let file =
f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned());
@@ -156,7 +160,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag
Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style),
None => "missing".into(),
};
(format!("{family} {style}"), detail)
(super::fonts::font_label(used), detail)
})
.collect()
}
@@ -243,7 +247,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
NodeKind::Compound { .. } => Some("compoundPaths"),
NodeKind::Text(t) => {
for run in &t.runs {
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone()));
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone(), run.style.font_version.clone()));
}
Some("textObjects")
}
@@ -300,7 +304,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
}
});
}
let font_names: Vec<String> = fonts.iter().map(|(f, s)| format!("{f} {s}")).collect();
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
let mut out = json!({
"document": document_summary(d),
"objects": counts,
+31 -13
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fonts in Document",
[],
None,
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
has_doc,
fonts
),
@@ -85,12 +85,23 @@ fn scope(s: &Session, selection_only: bool) -> Result<Vec<NodeId>> {
Ok(v)
}
/// The fonts (family, style) of the type in the layers and symbols of `d`.
pub(super) fn used_fonts(d: &Document) -> BTreeSet<(String, String)> {
/// A font as type names it: family, style and, when it names one, the installed version.
pub(super) type UsedFont = (String, String, Option<String>);
/// How a font is named in reports: family and style, then the version the type names, if any.
pub(super) fn font_label((family, style, version): &UsedFont) -> String {
match version {
Some(v) => format!("{family} {style} ({v})"),
None => format!("{family} {style}"),
}
}
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
let mut fonts = BTreeSet::new();
let mut add = |n: &Node| {
if let NodeKind::Text(t) = &n.kind {
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())));
}
};
d.walk(&mut add);
@@ -106,10 +117,10 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> {
let db = vectorcraft_text::FontDb::global();
let mut missing: Vec<String> = used_fonts(d)
.into_iter()
.filter(|(family, style)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
.map(|(family, _)| family)
.filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
.map(|(family, _, _)| family)
.collect();
// Sorted by family: its styles are neighbours.
// Sorted by family: its styles and versions are neighbours.
missing.dedup();
(!missing.is_empty()).then(|| {
format!(
@@ -131,10 +142,10 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
let d = &s.doc()?.doc;
let db = vectorcraft_text::FontDb::global();
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>, String)> = BTreeMap::new();
let mut found: BTreeMap<UsedFont, (usize, Vec<NodeId>, String)> = BTreeMap::new();
for id in ids {
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())).or_default();
e.0 += 1;
e.2.push_str(&r.text);
if !e.1.contains(&id) {
@@ -144,20 +155,25 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
}
let list: Vec<Value> = found
.into_iter()
.map(|((family, style), (runs, objects, chars))| {
let resolved = db.resolve(&family, &style);
.map(|((family, style, version), (runs, objects, chars))| {
// The face the type is set in: the version it names when that one is installed.
let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
lacking.sort_unstable();
lacking.dedup();
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
json!({
let mut row = json!({
"family": family, "style": style, "runs": runs, "objects": objects.len(),
"missing": status == vectorcraft_text::FontMatch::Missing,
"status": status.as_str(),
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
"missingGlyphs": lacking.len(),
})
});
if let Some(v) = version {
row["version"] = json!(v);
}
row
})
.collect();
Ok(json!(list))
@@ -198,6 +214,8 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
.unwrap_or_else(|| r.style.font_style.clone());
r.style.font_family = tf.clone();
r.style.font_style = style;
// Another font: none of the old one's versions.
r.style.font_version = None;
changed = true;
n += 1;
}
+27 -1
View File
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
has_selection,
set_live_shape
),
@@ -1095,6 +1095,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let sides = p.get("sides").and_then(Value::as_u64);
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let angle = |k: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|a| a.is_finite())
.map(|a| Some(a.rem_euclid(360.0)))
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be an angle in degrees, not {v}"))),
}
};
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -1102,6 +1115,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
l.set_sides(n);
*path = l.to_path();
}
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut()
&& (pie_start.is_some() || pie_end.is_some() || invert)
{
if let LiveShape::Ellipse { pie, .. } = l {
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert {
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
}
}
*path = l.to_path();
}
if radius.is_none() && kind.is_none() {
continue;
}
+8 -4
View File
@@ -107,10 +107,14 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
let db = vectorcraft_text::FontDb::global();
let mut files = HashSet::new();
lines.push("FONTS".to_string());
for (family, style) in super::fonts::used_fonts(&st.doc) {
let font = format!("{family} {style}");
// Found by any of its names, as the canvas draws it.
let resolved = db.resolve(&family, &style).filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing);
for used in super::fonts::used_fonts(&st.doc) {
let font = super::fonts::font_label(&used);
let (family, style, version) = used;
// Found by any of its names, as the canvas draws it (in the version the type names).
let resolved = db
.resolve(&family, &style)
.filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing)
.map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
// A style the family lacks is shown in its closest style, whose file is copied.
let shown = match &resolved {
Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style),
+9
View File
@@ -475,6 +475,7 @@ impl Session {
let history_changed = p.history_states != self.prefs.history_states;
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions;
self.prefs = p;
if fonts_folder_changed {
let folder = self.prefs.fonts_folder.trim();
@@ -486,6 +487,10 @@ impl Session {
let _ = super::fonts::rescan(self, &serde_json::Value::Null);
}
}
if hyphen_exceptions_changed {
// Preferences › Hyphenation › Exceptions (#394): the layout reads the process-wide list.
vectorcraft_text::set_hyphenation_exceptions(&self.prefs.hyphenation_exceptions);
}
vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok());
for st in &mut self.docs {
if history_changed {
@@ -503,6 +508,10 @@ impl Session {
d.grid.subdivisions = self.prefs.grid_subdivisions;
st.revision += 1;
}
// Hyphenation exceptions change every text object's line breaks.
if hyphen_exceptions_changed {
st.revision += 1;
}
}
// Plug-ins in a newly set Additional Plug-ins Folder are installed.
super::plugin::sync_prefs(self);
+7 -1
View File
@@ -316,6 +316,10 @@ impl CharChange {
if let Some(v) = &self.style {
st.font_style = v.clone();
}
// Another font: none of the old one's versions.
if self.font.is_some() || self.style.is_some() {
st.font_version = None;
}
if let Some(v) = self.size {
st.size = v.clamp(0.1, 1296.0);
}
@@ -388,7 +392,9 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
let db = vectorcraft_text::FontDb::global();
let mut faces = std::collections::HashMap::new();
let mut covers = |st: &CharStyle, c: char| {
let face = faces.entry((st.font_family.clone(), st.font_style.clone())).or_insert_with(|| db.face(&st.font_family, &st.font_style));
let face = faces
.entry((st.font_family.clone(), st.font_style.clone(), st.font_version.clone()))
.or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()));
face.as_ref().map(|f| f.covers(c))
};
// Byte ranges that keep a font (family, style).
+11
View File
@@ -251,6 +251,13 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
node.appearance = st.appearance();
children.push(Arc::new(node));
}
// Underline and strikethrough bars become paths of their run's paint too (#847).
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), &t) {
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
node.appearance = st.appearance();
children.push(Arc::new(node));
}
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;
d.remove(tid)?;
if children.is_empty() {
@@ -416,6 +423,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &style {
st.font_style = f.clone();
}
// Another font: none of the old one's versions.
if font.is_some() || style.is_some() {
st.font_version = None;
}
if let Some(v) = size {
st.size = v.clamp(0.1, 1296.0);
}
+28
View File
@@ -279,3 +279,31 @@ fn a_pen_path_rounds_its_corners_but_not_its_ends() {
run(&mut s, json!({"id": e.0, "radius": 8}));
assert_eq!(node_path(&s, e), before);
}
/// #812: an ellipse's Pie Start and End Angle cut it as a pie; Invert Pie shows the other part;
/// one undo step each; a bad angle is an error.
#[test]
fn an_ellipse_takes_pie_angles_and_inverts() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap());
let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Path { live: Some(LiveShape::Ellipse { pie, .. }), path, .. } => (*pie, path.bounds().unwrap()),
k => panic!("{k:?}"),
};
s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap();
let (p, b) = pie(&s);
assert_eq!(p, (0.0, 90.0));
assert!((b.x0 - 100.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "the quarter up and right of the centre: {b:?}");
s.execute("object.setLiveShape", &json!({"invertPie": true})).unwrap();
let (p, b) = pie(&s);
assert_eq!(p, (90.0, 360.0));
assert!(b.x0.abs() < 1e-6 && (b.y1 - 100.0).abs() < 1e-6, "the other three quarters: {b:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pie(&s).0, (0.0, 90.0));
// 0 to 360 (or an end of 0) is the whole ellipse again.
s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 0})).unwrap();
assert_eq!(pie(&s).0, (0.0, 360.0));
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
}
+24
View File
@@ -670,3 +670,27 @@ world", "size": 20}),
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(dst).unwrap().kind else { panic!("not type") };
assert_eq!((t.first_style().size, t.first_style().fill.color().map(|c| c.to_hex())), (20.0, Some("#00ff00".into())));
}
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
#[test]
fn hyphenation_exceptions_preference_reaches_the_hyphenator() {
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
let mut s = Session::new();
set_pref(&mut s, "hyphenationExceptions", json!(""));
assert!(vectorcraft_text::hyphenation_exceptions().is_empty());
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let before = s.doc().unwrap().revision;
set_pref(&mut s, "hyphenationExceptions", json!("typography, hap-pen"));
assert_eq!(s.prefs.hyphenation_exceptions, "typography, hap-pen");
assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen");
assert!(s.doc().unwrap().revision > before, "type reflows");
set_pref(&mut s, "hyphenationExceptions", json!(""));
assert!(vectorcraft_text::hyphenation_exceptions().is_empty());
}
+65
View File
@@ -86,6 +86,50 @@ pub fn ellipse(r: Rect) -> PathData {
PathData::single(SubPath::new(anchors, true))
}
/// The ellipse inscribed in `r` cut as a pie: from `start` to `end` degrees, measured
/// counterclockwise on screen from 3 o'clock (as Illustrator's Pie Start and End Angle are), then
/// back through the centre. A pie spanning the whole turn (or nothing) is the whole [`ellipse`].
/// The arc is one cubic per quarter turn at most: the centre, then the arc's anchors.
pub fn ellipse_pie(r: Rect, start: f64, end: f64) -> PathData {
let span = (end - start).rem_euclid(360.0);
if !(start.is_finite() && end.is_finite()) || span < 1e-9 || 360.0 - span < 1e-9 {
return ellipse(r);
}
let r = r.abs();
let c = r.center();
let (rx, ry) = (r.width() / 2.0, r.height() / 2.0);
// Screen y runs down: counterclockwise on screen is a falling y.
let at = |deg: f64| {
let t = deg.to_radians();
Point::new(c.x + rx * t.cos(), c.y - ry * t.sin())
};
let tangent = |deg: f64| {
let t = deg.to_radians();
Vec2::new(-rx * t.sin(), -ry * t.cos())
};
let pieces = if span <= 90.0 {
1
} else if span <= 180.0 {
2
} else if span <= 270.0 {
3
} else {
4
};
let step = span / f64::from(pieces);
let k = 4.0 / 3.0 * (step.to_radians() / 4.0).tan();
let mut anchors = vec![Anchor::corner(c)];
for i in 0..=pieces {
let a = start + step * f64::from(i);
let (p, d) = (at(a), tangent(a) * k);
let h_in = if i == 0 { p } else { p - d };
let h_out = if i == pieces { p } else { p + d };
let kind = if i == 0 || i == pieces { AnchorKind::Corner } else { AnchorKind::Smooth };
anchors.push(Anchor { p, h_in, h_out, kind });
}
PathData::single(SubPath::new(anchors, true))
}
/// Regular polygon with `sides` (≥ 3), first vertex straight up from the centre.
pub fn polygon(center: Point, radius: f64, sides: u32, rotation_deg: f64) -> PathData {
let n = sides.max(3);
@@ -207,6 +251,27 @@ mod tests {
crate::corners::cut_corners(&rectangle(r), &radii, &[]).1.concat()
}
/// A pie is the arc from its start to its end angle, counterclockwise on screen, closed through
/// the centre; a whole turn (or none) is the ellipse.
#[test]
fn a_pie_is_its_arc_closed_through_the_centre() {
let r = Rect::new(0.0, 0.0, 200.0, 100.0);
assert_eq!(ellipse_pie(r, 0.0, 360.0), ellipse(r));
assert_eq!(ellipse_pie(r, 90.0, 90.0), ellipse(r));
// A quarter, from 3 o'clock up to 12: the centre, then the arc's two ends.
let q = ellipse_pie(r, 0.0, 90.0);
let a: Vec<Point> = q.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(a.len(), 3);
assert!(a[0].distance(Point::new(100.0, 50.0)) < 1e-9 && a[1].distance(Point::new(200.0, 50.0)) < 1e-9);
assert!(a[2].distance(Point::new(100.0, 0.0)) < 1e-9, "counterclockwise on screen: up");
assert_eq!(q.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((100.0, 0.0, 200.0, 50.0)));
// Three quarters, from 12 round to 3 o'clock: four pieces at most, the whole box covered.
let big = ellipse_pie(r, 90.0, 0.0);
assert!(big.subpaths[0].anchors.len() <= 6 && big.subpaths[0].closed);
assert_eq!(big.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((0.0, 0.0, 200.0, 100.0)));
assert!(ellipse_pie(r, f64::NAN, 10.0).anchor_count() == 4, "not a number: the whole ellipse");
}
#[test]
fn rect_is_4_corners_clockwise() {
let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));
+15 -1
View File
@@ -1304,6 +1304,9 @@ impl Exporter<'_> {
let (below, above) = n.appearance.split_contents();
let all = (!n.appearance.items.is_empty()).then(|| {
let mut all = layout.to_bezpath();
for (_, bar) in vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t) {
all.extend(bar.iter());
}
all.apply_affine(t.xf);
to_path(&all).map(|p| (all, p))
});
@@ -1314,16 +1317,27 @@ impl Exporter<'_> {
s.push_transform(&xf(t.xf));
// Real text: the characters' fills as text in embedded fonts.
let plain = (!self.outline_text).then(|| t.plain_text());
let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t);
for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
// Underline and strikethrough bars are paths, with real text too (#847).
let mut run_bars = BezPath::new();
for (_, bar) in bars.iter().filter(|(r, _)| *r == i) {
run_bars.extend(bar.iter());
}
bp.extend(run_bars.iter());
let fill = &run.style.fill;
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) {
Some(text) => Cow::Owned(ex.glyph_text(s, &layout, i, text, fill)),
Some(text) => {
let mut left = ex.glyph_text(s, &layout, i, text, fill);
left.extend(run_bars.iter());
Cow::Owned(left)
}
None => Cow::Borrowed(&bp),
};
let opaque = (1.0, BlendMode::Normal);
+22 -1
View File
@@ -460,8 +460,12 @@ fn matching_face(
return Some(face.clone());
}
let db = vectorcraft_text::FontDb::global();
let others =
// Other installed versions of its family and style (a font library keeps old ones), then the
// family's other styles.
let versions = db.versions(face).into_iter().skip(1).take(16);
let styles =
db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s));
let others = versions.chain(styles);
let mut best = own.map(|d| (d, face.clone()));
for f in others {
if let Some(d) = glyph_deviation(&f, text, o, vertical)
@@ -636,6 +640,9 @@ struct Builder<'p> {
/// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph
/// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines.
matched: HashMap<u128, Option<Arc<vectorcraft_text::FontFace>>>,
/// Per font: the installed version its type names, when it isn't the one its family and style
/// resolve to (see `CharStyle::font_version`).
versions: HashMap<u128, Option<String>>,
/// The soft mask of the graphics state, the art drawn through it so far, and the masks read.
mask: Option<Arc<MaskSpec>>,
masked: Vec<Arc<Node>>,
@@ -784,6 +791,7 @@ impl<'p> Builder<'p> {
cid_text: HashMap::new(),
font_chars: HashMap::new(),
matched: HashMap::new(),
versions: HashMap::new(),
mask: None,
masked: vec![],
masks: HashMap::new(),
@@ -1356,12 +1364,25 @@ impl<'p> Builder<'p> {
};
info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) };
}
// A version of the family and style that the two alone don't resolve to: the type names it.
// Kept per font once its face is decided (until then each glyph asks).
let named = |f: &Option<Arc<vectorcraft_text::FontFace>>| {
let f = f.as_ref()?;
let default = vectorcraft_text::FontDb::global().face(&f.family, &f.style)?;
(default.version != f.version).then(|| f.version.clone())
};
let version = match self.versions.get(&key) {
Some(v) => v.clone(),
None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(),
None => named(&info.face),
};
let (paint, opacity) = self.paint(paint, stroke.is_some());
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
let look = Look {
font: key,
family: info.family,
style: info.style,
version,
size: at.size,
h_scale: at.h_scale,
fill: stroke.is_none().then_some(paint),
+3
View File
@@ -75,6 +75,8 @@ pub(crate) struct Look {
pub font: u128,
pub family: String,
pub style: String,
/// The installed version of the font when it isn't the one family and style resolve to.
pub version: Option<String>,
pub size: f64,
pub h_scale: f64,
pub fill: Option<Paint>,
@@ -97,6 +99,7 @@ impl Look {
CharStyle {
font_family: self.family.clone(),
font_style: self.style.clone(),
font_version: self.version.clone(),
size: round(self.size),
h_scale: round(self.h_scale),
fill: self.fill.clone().unwrap_or(Paint::None),
+2
View File
@@ -260,6 +260,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
font: 1,
family: "Source Sans 3".into(),
style: "Regular".into(),
version: None,
size: 20.0,
h_scale: 100.0,
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
@@ -282,6 +283,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
font: 1,
family: "Source Sans 3".into(),
style: "Regular".into(),
version: None,
size: 20.0,
h_scale: 100.0,
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
+23
View File
@@ -290,3 +290,26 @@ fn slanted_type_reopens_as_type_that_still_leans() {
assert!(off < 0.5, "{turn} {lean}: {a:?} vs {b:?}");
}
}
/// Type set in an installed font's older version (a library keeps both, one name) reopens as type
/// in that version: the file's glyphs are matched to it rather than kept as outlines, and the type
/// names the version, which the family and style alone don't pick.
#[test]
fn type_in_another_installed_version_reopens_in_that_version() {
use vectorcraft_text::test_fonts::{TWIN_FAMILY, TWIN_VERSIONS, twin_font};
// The newer version first: the one the family and style resolve to.
FontDb::global().add_font(twin_font(true).unwrap());
FontDb::global().add_font(twin_font(false).unwrap());
for version in [None, Some(TWIN_VERSIONS[0])] {
let st = CharStyle { font_family: TWIN_FAMILY.into(), font_version: version.map(Into::into), ..style(40.0) };
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), "Hamburgefonstiv", st)]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
let mut found = vec![];
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
found.push((t.plain_text(), t.first_style().font_version.clone()));
}
});
assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}");
}
}
+12 -1
View File
@@ -1653,7 +1653,11 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
.runs
.iter()
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())),
// The version the type names, when it's installed (see `FontDb::face_version`).
Some((f, m)) => {
let face = db.face_version(&r.style.font_family, &r.style.font_style, r.style.font_version.as_deref()).unwrap_or(f);
(m == vectorcraft_text::FontMatch::Missing, Some(face.id()))
}
None => (true, None),
})
.collect();
@@ -1676,6 +1680,13 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
target.extend(cell.iter());
}
// Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
if let Some(r) = runs.get_mut(run) {
r.extend(bar.iter());
}
all.extend(bar.iter());
}
TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines }
}
+15 -7
View File
@@ -1638,8 +1638,13 @@ impl Writer<'_> {
let a = self.attrs(&css::transparency(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let pd = PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf);
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t);
let mut all = lay.to_bezpath();
for (_, bar) in vectorcraft_text::decorations(&lay, db, t) {
all.extend(bar.iter());
}
let pd = PathData::from_bezpath(&all).transformed(t.xf);
let d = self.path_d(&pd, self.xf);
let tb = Some(t.xf.transform_rect_bbox(lay.bounds));
let (below, above) = n.appearance.split_contents();
@@ -1671,12 +1676,15 @@ impl Writer<'_> {
/// Text as glyph outlines: one compound path per run, painted like the run (gradients span
/// the whole text, as on the canvas).
fn text_outlines(&mut self, n: &Node, t: &TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t);
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![];
for g in &lay.glyphs {
match runs.last_mut() {
Some((r, bp)) if *r == g.run => bp.extend(g.outline.iter()),
_ => runs.push((g.run, g.outline.clone())),
// Underline and strikethrough bars join their run's outlines.
let bars = vectorcraft_text::decorations(&lay, db, t);
for (run, outline) in lay.glyphs.iter().map(|g| (g.run, &g.outline)).chain(bars.iter().map(|(r, b)| (*r, b))) {
match runs.iter_mut().find(|(r, _)| *r == run) {
Some((_, bp)) => bp.extend(outline.iter()),
None => runs.push((run, outline.clone())),
}
}
let id = self.id_attr(n);
+65 -7
View File
@@ -7,25 +7,49 @@
//! Web (wasm32) builds embed only the UI font, BIZ UDPGothic Regular (~4.7 MB): the browser has no
//! system fonts, and all four fonts (~24 MB) would push the `.wasm` past static hosts' per-file
//! limits (Cloudflare Pages: 25 MiB).
//!
//! It also writes `WEB_FONTS`: the faces listed in `web-fonts.txt`, which web builds fetch from
//! `fonts/<sha16>/` beside the wasm instead of embedding (`cargo xtask web-fonts` copies them
//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is left
//! out with a warning, never an error, and never costs the desktop its fonts.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
#[path = "build/web_fonts.rs"]
mod web_fonts;
fn main() {
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
println!("cargo::rerun-if-changed=web-fonts.txt");
println!("cargo::rerun-if-changed=build/web_fonts.rs");
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
let (mut fonts, mut web) = (String::new(), String::new());
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from) {
let dir = from_workspace(dir);
match craft_fonts(&dir) {
Ok(entries) => src.push_str(&entries),
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Ok(entries) => fonts = entries,
Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()),
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
// Separately: a bad web list must not cost the desktop build its craft fonts. A listed face
// the checkout lacks (an older craft-fonts pin) is left out with a warning, never an error,
// so the apps don't depend on which craft-fonts commit CI pins: that build just fetches
// fewer fonts.
match web_font_entries(&dir) {
Ok((entries, missing)) => {
web = entries;
if !missing.is_empty() {
let msg = format!("{} not in CRAFT_FONTS_DIR={} (an older craft-fonts?)", missing.join(", "), dir.display());
println!("cargo::warning=web build won't fetch: {msg}");
}
}
Err(e) if required => println!("cargo::error=web-fonts.txt: {e}"),
Err(e) => println!("cargo::warning=web build fetches no craft fonts: web-fonts.txt: {e}"),
}
}
src.push_str("];\n");
let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n");
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
if let Err(e) = std::fs::write(&out, src) {
println!("cargo::error=writing {}: {e}", out.display());
@@ -72,3 +96,37 @@ fn craft_fonts(dir: &Path) -> Result<String, String> {
}
Ok(out)
}
/// One `WebFont { .. }` initialiser per `web-fonts.txt` line, from its craft-fonts manifest row, and
/// the listed faces the manifest lacks (an older craft-fonts checkout), which are left out.
fn web_font_entries(dir: &Path) -> Result<(String, Vec<String>), String> {
let list_path = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()).join("web-fonts.txt");
let list = std::fs::read_to_string(&list_path).map_err(|e| format!("{}: {e}", list_path.display()))?;
let manifest_path = dir.join("fonts/manifest.txt");
// WEB_FONTS follows the manifest (URLs and hashes), on every target.
println!("cargo::rerun-if-changed={}", manifest_path.display());
let manifest = std::fs::read_to_string(&manifest_path).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
let rows: Vec<Vec<&str>> =
manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect();
let (mut out, mut missing) = (String::new(), Vec::new());
for e in web_fonts::parse_list(&list)? {
let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else {
missing.push(format!("{} {}", e.family, e.style));
continue;
};
let (Some(file), Some(sha)) = (row.get(2), row.get(6)) else {
return Err(format!("malformed manifest row for {} {}", e.family, e.style));
};
let integrity = web_fonts::sri(sha).ok_or_else(|| format!("bad sha256 for {} {}", e.family, e.style))?;
let _ = writeln!(
out,
" WebFont {{ family: {:?}, style: {:?}, startup: {}, url: {:?}, integrity: {:?} }},",
e.family,
e.style,
e.startup,
web_fonts::url(sha, file),
integrity,
);
}
Ok((out, missing))
}
+118
View File
@@ -0,0 +1,118 @@
//! `web-fonts.txt` (the craft-fonts faces web builds fetch instead of embedding) and the
//! `WEB_FONTS` fields built from it. Shared by `build.rs` and the crate's tests (`#[path]`-included
//! by both, so it has no dependencies), and by `xtask/src/web_fonts.rs`, which copies the files to [`url`].
/// One line of `web-fonts.txt`: `when | family | style`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Listed<'a> {
/// `startup` (fetched before the app starts) rather than `background`.
pub startup: bool,
pub family: &'a str,
pub style: &'a str,
}
/// Parse `web-fonts.txt`. Blank lines and `#` comments are skipped; line numbers are 1-based.
pub fn parse_list(text: &str) -> Result<Vec<Listed<'_>>, String> {
let mut out = Vec::new();
for (i, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [when, family, style] = fields.as_slice() else {
return Err(format!("web-fonts.txt line {}: expected `when | family | style`", i + 1));
};
let startup = match *when {
"startup" => true,
"background" => false,
other => return Err(format!("web-fonts.txt line {}: `{other}` is neither startup nor background", i + 1)),
};
if family.is_empty() || style.is_empty() {
return Err(format!("web-fonts.txt line {}: empty family or style", i + 1));
}
out.push(Listed { startup, family, style });
}
Ok(out)
}
/// Standard base64 (RFC 4648, with padding).
pub fn base64(data: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = |i: usize| u32::from(chunk.get(i).copied().unwrap_or(0));
let n = (b(0) << 16) | (b(1) << 8) | b(2);
for k in 0..4 {
if k <= chunk.len() {
let six = ((n >> (18 - 6 * k)) & 63) as usize;
s.push(TABLE.get(six).map_or('=', |&c| char::from(c)));
} else {
s.push('=');
}
}
}
s
}
/// The Subresource Integrity value (`sha256-<base64>`) for a 64-digit hex SHA-256; `None` if `hex`
/// isn't one.
pub fn sri(hex: &str) -> Option<String> {
if hex.len() != 64 {
return None;
}
let bytes: Option<Vec<u8>> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
Some(format!("sha256-{}", base64(&bytes?)))
}
/// The site-relative URL a face is served at: `fonts/<first 16 hex digits of its SHA-256>/<file
/// name>`. Relative, so the site works under any path; content-addressed, so it can be cached
/// forever.
pub fn url(sha256: &str, file: &str) -> String {
let name = file.rsplit('/').next().unwrap_or(file);
format!("fonts/{}/{name}", sha256.get(..16).unwrap_or(sha256))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_matches_rfc_4648_vectors() {
for (input, want) in
[("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
{
assert_eq!(base64(input.as_bytes()), want, "{input:?}");
}
}
#[test]
fn sri_of_the_empty_files_sha256() {
let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert_eq!(sri(empty).as_deref(), Some("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="));
assert_eq!(sri("abc"), None);
assert_eq!(sri(&"zz".repeat(32)), None);
}
#[test]
fn url_is_relative_and_content_addressed() {
let sha = "63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e";
assert_eq!(url(sha, "fonts/noto-sans-arabic/NotoSansArabic.ttf"), "fonts/63111b5b2e074dd4/NotoSansArabic.ttf");
assert!(!url(sha, "x.ttf").starts_with('/'));
}
#[test]
fn list_parsing() {
let l = parse_list("# c\n\nstartup | Noto Sans Arabic | Regular\nbackground | Amiri | Bold Italic\n").unwrap();
assert_eq!(
l,
[
Listed { startup: true, family: "Noto Sans Arabic", style: "Regular" },
Listed { startup: false, family: "Amiri", style: "Bold Italic" }
]
);
assert!(parse_list("soon | A | B").unwrap_err().contains("line 1"));
assert!(parse_list("startup | A").is_err());
assert!(parse_list("startup | | B").is_err());
}
}
+60 -1
View File
@@ -1,7 +1,7 @@
//! Fonts from the optional craft-fonts build input (<https://github.com/storytold/craft-fonts>).
//!
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
//! manifest (today the Japanese UI and document fonts); built without it, it is empty and Japanese
//! manifest (Japanese UI and document fonts, and Arabic fonts); built without it, it is empty and Japanese
//! text falls back to the installed system fonts. Release builds always set it. See `build.rs`
//! and craftrules `standards/fonts.md`.
@@ -14,6 +14,20 @@ pub struct CraftFont {
pub bytes: &'static [u8],
}
/// A craft-fonts face the web (wasm32) build fetches from beside the wasm instead of embedding,
/// listed in `crates/text/web-fonts.txt` (empty unless built with `CRAFT_FONTS_DIR`). Fetched by
/// `apps/vectorcraft-web/src/fonts.rs`; copied into the site by `cargo xtask web-fonts`.
pub struct WebFont {
pub family: &'static str,
pub style: &'static str,
/// Fetched before the app starts (a fallback the first frame needs), else in the background.
pub startup: bool,
/// Site-relative URL, `fonts/<first 16 hex digits of the SHA-256>/<file name>`.
pub url: &'static str,
/// Subresource Integrity value, `sha256-<base64>`; the browser rejects other bytes.
pub integrity: &'static str,
}
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
impl CraftFont {
@@ -21,6 +35,10 @@ impl CraftFont {
pub fn is_japanese(&self) -> bool {
self.scripts.contains(&"Jpan")
}
/// Is the font for Arabic text?
pub fn is_arabic(&self) -> bool {
self.scripts.contains(&"Arab")
}
/// A serif (Mincho) family, for document text.
pub fn is_mincho(&self) -> bool {
self.family.contains("Mincho")
@@ -43,6 +61,16 @@ pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
v
}
/// The craft-fonts faces the document font database loads: the Japanese ones in
/// [`japanese_document_fonts`] order, then the Arabic ones in manifest order (Noto Sans Arabic
/// first: the fallback for Arabic text; the other families are picked by name). Empty when built
/// without craft-fonts.
pub fn document_fonts() -> Vec<&'static CraftFont> {
let mut v = japanese_document_fonts();
v.extend(CRAFT_FONTS.iter().filter(|f| f.is_arabic() && !f.is_japanese()));
v
}
/// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then
/// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts.
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
@@ -50,3 +78,34 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold));
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn web_fonts_follow_the_list() {
if CRAFT_FONTS.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
return;
}
// The listed faces this craft-fonts checkout has (an older one lacks some; build.rs leaves
// those out with a warning).
let listed: Vec<_> = crate::web_fonts_build::parse_list(include_str!("../web-fonts.txt"))
.unwrap()
.into_iter()
.filter(|l| CRAFT_FONTS.iter().any(|f| f.family == l.family && f.style == l.style))
.collect();
assert_eq!(WEB_FONTS.len(), listed.len());
for (l, w) in listed.iter().zip(WEB_FONTS) {
assert_eq!((l.family, l.style, l.startup), (w.family, w.style, w.startup));
let (dir, name) = w.url.strip_prefix("fonts/").and_then(|r| r.split_once('/')).unwrap();
assert!(dir.len() == 16 && dir.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), "{}", w.url);
assert!(name.ends_with(".ttf") || name.ends_with(".otf"), "{}", w.url);
assert!(w.integrity.starts_with("sha256-") && w.integrity.len() == "sha256-".len() + 44, "{}", w.integrity);
}
let startup: Vec<_> = WEB_FONTS.iter().filter(|w| w.startup).map(|w| w.family).collect();
let noto = CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic");
assert_eq!(startup, if noto { vec!["Noto Sans Arabic"] } else { vec![] });
}
}
+90 -8
View File
@@ -52,6 +52,9 @@ pub struct FontFace {
pub family: String,
/// Typographic style name (e.g. "Semibold", "Italic").
pub style: String,
/// The font's version string (name ID 5, e.g. "Version 7.200"; empty when it has none). Two
/// installed versions of one family and style are told apart by it (see [`FontDb::face_version`]).
pub version: String,
/// usWeightClass-style weight (400 = regular).
pub weight: f32,
pub italic: bool,
@@ -69,6 +72,10 @@ pub struct FontFace {
/// values).
pub(crate) cap_height: f64,
pub(crate) x_height: f64,
/// Underline and strikeout: (offset from the baseline up to the line's top, thickness), in
/// font units (the `post` and OS/2 tables', else a common placement).
pub(crate) underline: (f64, f64),
pub(crate) strikeout: (f64, f64),
pub(crate) shaper: harfrust::ShaperData,
/// The axis settings of the variable font's named instance this face is (user units: `wght`
/// 700); empty for a static face or a variable font's default instance.
@@ -324,6 +331,10 @@ pub struct FontDb {
/// scan, so what they find never depends on what ran before them.
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock<()>,
/// Families (lowercased) whose installed files were all loaded to find other versions of their
/// faces (see [`FontDb::versions`]); a rescan forgets them.
#[cfg(not(target_arch = "wasm32"))]
version_scanned: RwLock<std::collections::HashSet<String>>,
/// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned.
family_cache: Mutex<Option<Arc<[String]>>>,
menu_cache: Mutex<Option<MenuFamilies>>,
@@ -1032,6 +1043,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
};
let f = skrifa::FontRef::from_index(data, index).ok()?;
let FaceStyle { family, style, keys, variations, weight, italic, traits } = spec;
let version = name(&f, &[StringId::VERSION_STRING]).unwrap_or_default();
// A named instance: outlines, metrics and advances at its axis settings.
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
let m = f.metrics(Size::unscaled(), &location);
@@ -1040,22 +1052,32 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
let shaper = harfrust::ShaperData::new(&hb);
let instance =
(!variations.is_empty()).then(|| harfrust::ShaperInstance::from_variations(&hb, variations.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))));
let upem = m.units_per_em.max(1) as f64;
let x_height = m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5);
let decoration = |d: Option<skrifa::metrics::Decoration>, fallback: (f64, f64)| {
d.map(|d| (f64::from(d.offset), f64::from(d.thickness))).filter(|(o, t)| o.is_finite() && *t > 0.0 && t.is_finite()).unwrap_or(fallback)
};
let underline = decoration(m.underline, (-0.075 * upem, 0.05 * upem));
let strikeout = decoration(m.strikeout, (x_height * 0.5 + 0.025 * upem, 0.05 * upem));
Some(FontFace {
keys,
traits,
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
version,
weight: weight.unwrap_or(a.weight.value()),
italic: italic || !matches!(a.style, skrifa::attribute::Style::Normal),
variations,
location,
instance,
upem: m.units_per_em.max(1) as f64,
upem,
ascent: m.ascent as f64,
descent: -(m.descent as f64),
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
x_height,
underline,
strikeout,
shaper,
bytes,
index,
@@ -1130,9 +1152,10 @@ impl FontDb {
fn new(font_paths: FontPaths) -> Self {
let mut faces = Vec::new();
// The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho
// first: fallbacks for Japanese text after the requested and bundled fonts.
let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes);
// The bundled fonts, then the craft-fonts faces (when built with them): Japanese, Mincho
// first, then Arabic with Noto Sans Arabic first. They are fallbacks for those scripts
// after the requested and bundled fonts, and families users can pick by name.
let craft = crate::craft_fonts::document_fonts().into_iter().map(|f| f.bytes);
for data in BUNDLED.iter().copied().chain(craft) {
for (i, spec) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, spec, None) {
@@ -1149,6 +1172,8 @@ impl FontDb {
aliases: RwLock::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock::new(),
#[cfg(not(target_arch = "wasm32"))]
version_scanned: RwLock::new(std::collections::HashSet::new()),
family_cache: Mutex::new(None),
menu_cache: Mutex::new(None),
generation: AtomicU64::new(0),
@@ -1241,8 +1266,13 @@ impl FontDb {
pub fn styles(&self, family: &str) -> Vec<String> {
let (family, _) = self.canonical(family, "");
let family = family.as_str();
let mut v: Vec<(bool, f32, String)> =
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
let mut v: Vec<(bool, f32, String)> = Vec::new();
// Each style once, as its first loaded version describes it (versions can disagree on weight).
for f in self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)) {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&f.style)) {
v.push((f.italic, f.weight, f.style.clone()));
}
}
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
for cf in &c.faces {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) {
@@ -1266,7 +1296,14 @@ impl FontDb {
let data = Arc::new(bytes);
let mut added = 0;
for (i, spec) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) {
// Another version of a family and style is kept: a document may name it (the first one
// loaded stays the one the family and style alone resolve to).
let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default();
if self
.read_faces()
.iter()
.any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style) && f.version == version)
{
continue;
}
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) {
@@ -1328,6 +1365,8 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
fn scan_font_dirs(&self) -> usize {
// Files may have been installed since: other versions are looked for again.
self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).clear();
let mut catalog = Catalog::new();
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
let mut postscript = PostScriptNames::new();
@@ -1519,6 +1558,49 @@ impl FontDb {
.or_else(|| self.read_faces().first().cloned())
}
/// [`FontDb::face`] in version `version` of the family and style (a [`FontFace::version`]), when
/// that version is installed; otherwise (or without a version) the face [`FontDb::face`] gives.
pub fn face_version(&self, family: &str, style: &str, version: Option<&str>) -> Option<Arc<FontFace>> {
let face = self.face(family, style)?;
let Some(version) = version.filter(|v| *v != face.version) else { return Some(face) };
let same =
|f: &&Arc<FontFace>| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.version == version;
if let Some(f) = self.read_faces().iter().find(same) {
return Some(f.clone());
}
// Not loaded: the family's installed files may hold it (the catalog doesn't list versions).
self.load_family_files(&face.family);
let found = self.read_faces().iter().find(same).cloned();
Some(found.unwrap_or(face))
}
/// Every installed version of `face`'s family and style, `face` itself first.
pub fn versions(&self, face: &Arc<FontFace>) -> Vec<Arc<FontFace>> {
self.load_family_files(&face.family);
let mut out = vec![face.clone()];
out.extend(
self.read_faces()
.iter()
.filter(|f| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.id != face.id)
.cloned(),
);
out
}
/// Load the installed files of `family` once (other versions of faces already loaded from
/// elsewhere); a no-op on wasm, where no fonts are installed.
fn load_family_files(&self, family: &str) {
#[cfg(not(target_arch = "wasm32"))]
{
let key = family.to_ascii_lowercase();
if self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).insert(key) {
self.load_cataloged(family);
}
}
#[cfg(target_arch = "wasm32")]
let _ = family;
}
/// Is `style` of `family` among the installed fonts?
#[cfg(not(target_arch = "wasm32"))]
fn is_cataloged(&self, family: &str, style: &str) -> bool {
+163
View File
@@ -1,12 +1,108 @@
//! Pattern-free English hyphenation: break long words between syllables at vowel–consonant
//! boundaries (V-CV: "ty-po", and VC-CV: "hap-pen"), never leaving fewer than [`MIN_BEFORE`]
//! letters before or [`MIN_AFTER`] after the hyphen.
//!
//! Preferences › Hyphenation › Exceptions (comma separated) override the points for named words:
//! `typography` never breaks, `ty-pog-ra-phy` breaks only where the hyphens mark. See
//! [`set_hyphenation_exceptions`].
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard, OnceLock};
/// Only words at least this long are hyphenated (Illustrator's default: longer than 5 letters).
pub const MIN_WORD: usize = 6;
pub const MIN_BEFORE: usize = 3;
pub const MIN_AFTER: usize = 3;
/// An exception for one word: never break it, or break only at the listed character indices.
#[derive(Clone, Debug, PartialEq, Eq)]
enum Exception {
Never,
Only(Vec<usize>),
}
#[derive(Default)]
struct Exceptions {
source: String,
map: HashMap<String, Exception>,
}
fn store() -> MutexGuard<'static, Exceptions> {
static LOCK: OnceLock<Mutex<Exceptions>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(Exceptions::default())).lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn parse_list(source: &str) -> Exceptions {
let mut map = HashMap::new();
for entry in source.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (word, ex) = parse_exception(entry);
if word.is_empty() {
continue;
}
map.insert(word, ex);
}
Exceptions { source: source.to_string(), map }
}
/// Preferences › Hyphenation › Exceptions: comma-separated words. A word with no hyphens is never
/// hyphenated; hyphens mark the only allowed break points (`ty-pog-ra-phy`). Matching is
/// case-insensitive. Empty clears the list.
pub fn set_hyphenation_exceptions(source: &str) {
*store() = parse_list(source);
}
/// The last string passed to [`set_hyphenation_exceptions`] (empty when none).
pub fn hyphenation_exceptions() -> String {
store().source.clone()
}
fn parse_exception(entry: &str) -> (String, Exception) {
// Illustrator: bare word = never break; hyphens mark the only allowed breaks. No other syntax.
if !entry.contains('-') {
return (normalize_word(entry), Exception::Never);
}
let mut word = String::new();
let mut points = Vec::new();
for ch in entry.chars() {
if ch == '-' {
let at = word.chars().count();
if at > 0 {
points.push(at);
}
} else {
for c in ch.to_lowercase() {
word.push(c);
}
}
}
let n = word.chars().count();
points.retain(|&p| p > 0 && p < n);
points.sort_unstable();
points.dedup();
(word, Exception::Only(points))
}
fn normalize_word(word: &str) -> String {
word.chars().flat_map(|c| c.to_lowercase()).collect()
}
/// Look the word up under the preferences lock (or the thread-local test override). Only the matched
/// [`Exception`] is cloned, never the whole list.
fn exception_for(word: &str) -> Option<Exception> {
let key = normalize_word(word);
#[cfg(test)]
{
if let Some(ex) = TEST_OVERRIDE.with(|c| c.borrow().as_ref().and_then(|e| e.map.get(&key).cloned())) {
return Some(ex);
}
}
store().map.get(&key).cloned()
}
fn is_vowel(c: char) -> bool {
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
}
@@ -25,7 +121,14 @@ fn onset_pair(a: char, b: char) -> bool {
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char).
/// Words containing non-letters (other than a trailing apostrophe) are not hyphenated.
/// Preferences exceptions override the pattern for a matching word.
pub fn hyphen_points(word: &str) -> Vec<usize> {
if let Some(ex) = exception_for(word) {
return match ex {
Exception::Never => vec![],
Exception::Only(pts) => pts,
};
}
let c: Vec<char> = word.chars().collect();
let n = c.len();
if n < MIN_WORD || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) {
@@ -69,3 +172,63 @@ pub fn hyphenate_word(word: &str) -> String {
}
s
}
#[cfg(test)]
thread_local! {
/// Per-thread list so unit tests don't race the process-wide preferences store.
static TEST_OVERRIDE: std::cell::RefCell<Option<Exceptions>> = const { std::cell::RefCell::new(None) };
}
/// Run `f` with Preferences › Hyphenation › Exceptions applied on this thread only (tests).
#[cfg(test)]
pub(crate) fn with_hyphenation_exceptions_for_test(source: &str, f: impl FnOnce()) {
TEST_OVERRIDE.with(|c| *c.borrow_mut() = Some(parse_list(source)));
f();
TEST_OVERRIDE.with(|c| *c.borrow_mut() = None);
}
#[cfg(test)]
mod tests {
use super::*;
fn with_exceptions(source: &str, f: impl FnOnce()) {
with_hyphenation_exceptions_for_test(source, f);
}
/// #394 Preferences › Hyphenation › Exceptions contract (parse + override).
#[test]
fn hyphenation_exceptions_override_the_pattern() {
let pattern = hyphen_points("typography");
assert_eq!(pattern, vec![4, 7], "pattern: typo-gra-phy");
// Bare word: never break (case-insensitive).
with_exceptions("typography", || {
assert!(hyphen_points("typography").is_empty());
assert!(hyphen_points("Typography").is_empty(), "case-insensitive");
});
// Hyphens mark the only allowed breaks (stricter than the pattern).
with_exceptions("typo-graphy", || {
assert_eq!(hyphen_points("typography"), vec![4], "only the marked break");
assert_eq!(hyphenate_word("typography"), "typo-graphy");
});
// Comma-separated list; empty / whitespace entries ignored.
with_exceptions(" , typography , hap-pen , ", || {
assert!(hyphen_points("typography").is_empty());
assert_eq!(hyphen_points("happen"), vec![3]);
assert!(!hyphen_points("hyphenation").is_empty(), "unlisted words still use the pattern");
});
assert_eq!(hyphen_points("typography"), pattern, "override cleared");
}
/// Process-wide store used by `prefs.set` / restore.
#[test]
fn set_hyphenation_exceptions_round_trips_the_source_string() {
set_hyphenation_exceptions("typography, hap-pen");
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
set_hyphenation_exceptions("");
assert!(hyphenation_exceptions().is_empty());
}
}
+44 -2
View File
@@ -146,6 +146,46 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
layout_with(db, t, &opts)
}
/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a
/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's
/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift
/// and scaling follow) → (run, bar in text space). Type on a path and vertical type have none yet.
pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usize, BezPath)> {
let mut out = vec![];
if layout.on_path || layout.vertical {
return out;
}
let glyphs = &layout.glyphs;
let mut i = 0;
while let Some(g) = glyphs.get(i) {
// The stretch: the glyphs of this run on this line, in a row.
let end = glyphs.iter().skip(i).position(|h| h.run != g.run || h.line != g.line).map_or(glyphs.len(), |n| i + n);
let stretch = glyphs.get(i..end).unwrap_or_default();
i = end.max(i + 1);
let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue };
if !(style.underline || style.strikethrough) {
continue;
}
let Some(face) = db.face_by_id(g.font_id) else { continue };
let (x0, x1) = stretch.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| {
let (s, e) = (h.origin.x, h.origin.x + h.advance);
(a.min(s).min(e), b.max(s).max(e))
});
for (on, (offset, thickness)) in [(style.underline, face.underline), (style.strikethrough, face.strikeout)] {
if !on {
continue;
}
// Font units run y down in the glyph's transform: the bar's top is at -offset.
let (top, bottom) = ((g.xf * Point::new(0.0, -offset)).y, (g.xf * Point::new(0.0, -offset + thickness)).y);
let bar = Rect::new(x0, top.min(bottom), x1, top.max(bottom));
if bar.width() > 0.0 && bar.height() > 0.0 && bar.width().is_finite() && bar.height().is_finite() {
out.push((g.run, bar.to_path(0.1)));
}
}
}
out
}
/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass).
const SHRINK_PASSES: usize = 11;
@@ -263,7 +303,8 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
TextKind::OnPath { .. } => Affine::IDENTITY,
TextKind::Point => {
let first = cx.style_at(0);
let centre = db.face(&first.font_family, &first.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
let centre =
db.face_version(&first.font_family, &first.font_style, first.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN
}
_ => QUARTER_TURN,
@@ -1392,7 +1433,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
let st = cx.style_at(pr.start);
let (cap, xh) = cap_x_heights(cx.db, st);
let centre = cx.db.face(&st.font_family, &st.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
let centre =
cx.db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 }
};
if pi > 0 {
+8 -2
View File
@@ -24,14 +24,15 @@ mod suitcase;
pub mod test_fonts;
pub mod thread;
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
pub use fontdb::{
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, set_user_font_dirs,
style_weight, system_font_dirs, user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
pub use layout::{decorations, layout, layout_with};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign};
@@ -522,4 +523,9 @@ mod tests_typo;
#[cfg(test)]
mod tests_variable;
#[cfg(test)]
mod tests_versions;
#[cfg(test)]
mod tests_vertical;
#[cfg(test)]
#[path = "../build/web_fonts.rs"]
mod web_fonts_build;
+8 -4
View File
@@ -221,7 +221,9 @@ pub(crate) fn is_cjk(c: char) -> bool {
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
let vs = st.v_scale / 100.0;
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()) };
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading());
};
let k = st.size / face.upem;
(face.ascent * k * vs, face.descent * k * vs, st.effective_leading())
}
@@ -229,7 +231,9 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
/// Cap height and x height (points) of a style's resolved face.
pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) {
let vs = st.v_scale / 100.0;
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.7 * vs, st.size * 0.5 * vs) };
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
return (st.size * 0.7 * vs, st.size * 0.5 * vs);
};
let k = st.size / face.upem * vs;
(face.cap_height * k, face.x_height * k)
}
@@ -291,12 +295,12 @@ pub(crate) fn shape_range(
let a = bytes.start;
let b = bytes.end;
if let Some(art) = art {
if let Some(face) = db.face(&st.font_family, &st.font_style).or_else(|| db.face_covering('a')) {
if let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).or_else(|| db.face_covering('a')) {
out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a)));
}
continue;
}
let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue };
let Some(primary) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { continue };
let pmap = primary.skrifa().map(|f| f.charmap());
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
let small_caps = st.small_caps.is_some() && !st.all_caps;
+27
View File
@@ -174,3 +174,30 @@ pub fn vertical_font(vorg: bool) -> Option<Vec<u8>> {
}
Some(builder.build())
}
/// The family of [`twin_font`]: two versions of one family and style.
pub const TWIN_FAMILY: &str = "Twintest Sans";
/// The version strings of [`twin_font`]'s older and newer face.
pub const TWIN_VERSIONS: [&str; 2] = ["Version 1.000", "Version 2.000"];
/// A face of family [`TWIN_FAMILY`], style Regular, in version [`TWIN_VERSIONS`]`[0]` (the bundled
/// Source Sans 3 Regular) or, `newer`, `[1]` (the bundled Inter Regular): one name, two sets of
/// glyphs, as a font library that keeps an old version beside a new one has. `None` when it can't
/// be built.
pub fn twin_font(newer: bool) -> Option<Vec<u8>> {
const OLDER: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
const NEWER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let font = skrifa::FontRef::new(if newer { NEWER } else { OLDER }).ok()?;
let version = TWIN_VERSIONS[usize::from(newer)];
let name = name_table(&[(1, TWIN_FAMILY), (2, "Regular"), (4, "Twintest Sans Regular"), (5, version), (6, "TwintestSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
+23
View File
@@ -138,6 +138,29 @@ fn tracking_and_scale() {
assert!(b.y1 < -4.0, "shifted up: {b:?}");
}
#[test]
fn underline_and_strikethrough_bars() {
let mut t = point("ab", style(20.0));
t.runs = vec![
TextRun { text: "ab".into(), style: style(20.0), inline: None },
TextRun { text: "cd".into(), style: CharStyle { underline: true, strikethrough: true, ..style(20.0) }, inline: None },
];
let l = layout(db(), &t);
let bars = decorations(&l, db(), &t);
assert_eq!(bars.len(), 2, "one underline and one strikethrough: {bars:?}");
assert!(bars.iter().all(|(run, _)| *run == 1), "only the styled run");
let (under, strike) = (bars[0].1.bounding_box(), bars[1].1.bounding_box());
// The bars span the run's glyphs: from "c" to the end of "d".
let (c, d) = (&l.glyphs[2], &l.glyphs[3]);
assert!((under.x0 - c.origin.x).abs() < 1e-6 && (under.x1 - d.origin.x - d.advance).abs() < 1e-6, "{under:?}");
// y runs down: the underline sits below the baseline, the strikethrough above it, both thin.
assert!(under.y0 > 0.0 && under.y1 < 5.0, "{under:?}");
assert!(strike.y1 < 0.0 && strike.y0 > -10.0, "{strike:?}");
assert!(under.height() < 3.0 && strike.height() < 3.0);
// No bars for plain text.
assert!(decorations(&layout(db(), &point("ab", style(20.0))), db(), &point("ab", style(20.0))).is_empty());
}
#[test]
fn all_caps() {
let a = width(&point("abc", CharStyle { all_caps: true, ..style(10.0) }));
+18
View File
@@ -344,3 +344,21 @@ fn paragraph_direction_is_per_paragraph() {
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);
}
#[test]
fn craft_arabic_fonts_load_and_noto_sans_arabic_is_the_arabic_fallback() {
let arabic: Vec<_> = crate::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab")).collect();
if arabic.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
return;
}
let db = FontDb::with_font_dirs(vec![]);
for f in &arabic {
assert!(db.has_family(f.family), "{} {} isn't loaded", f.family, f.style);
}
let beh = db.face_covering('ب').expect("some face covers U+0628");
assert_eq!(beh.family, "Noto Sans Arabic");
// Latin still falls back to a bundled face, not to one of the Arabic families.
let a = db.face_covering('a').expect("some face covers 'a'");
assert!(!arabic.iter().any(|f| f.family == a.family), "Latin now falls back to {}", a.family);
}
+20
View File
@@ -415,6 +415,26 @@ fn hyphenated_layout_adds_hyphens() {
}
}
/// Preferences › Hyphenation › Exceptions (#394): listing a word stops the layout from breaking it.
#[test]
fn hyphenation_exceptions_keep_listed_words_whole_in_layout() {
// Same copy as `hyphenated_layout_adds_hyphens` (known to produce soft hyphens at 120pt width).
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 120.0, 600.0), Justify::Left);
t.para.hyphenate = true;
let with_breaks = layout(db(), &t);
let hyphens = |l: &TextLayout| l.glyphs.iter().filter(|g| g.len == 0).count();
assert!(hyphens(&with_breaks) > 0, "pattern produces soft hyphens");
hyphen::with_hyphenation_exceptions_for_test(
"extraordinarily, comprehensive, typographical, considerations, notwithstanding, everything",
|| {
let blocked = layout(db(), &t);
assert_eq!(hyphens(&blocked), 0, "every long word listed: no soft hyphens");
assert!(blocked.lines.len() >= with_breaks.lines.len());
},
);
}
#[test]
fn soft_hyphen_is_invisible_mid_line() {
let l = layout(db(), &TextObject::point(Point::ZERO, "co\u{00AD}operate", style(20.0)));
+43
View File
@@ -0,0 +1,43 @@
//! Two installed versions of one family and style (a font library keeps an old version beside a
//! new one): both load, the family and style alone keep resolving to the first, and type that names
//! the other version is set in it.
use kurbo::Point;
use vectorcraft_doc::CharStyle;
use super::test_fonts::{TWIN_FAMILY as FAMILY, TWIN_VERSIONS as VERSIONS, twin_font};
use super::*;
/// A database of the bundled fonts, the newer version of the test family loaded first.
fn db() -> FontDb {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(twin_font(true).expect("the newer test font builds")), 1);
assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 1, "another version is kept");
assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 0, "the same version isn't added twice");
db
}
fn width(db: &FontDb, version: Option<&str>) -> f64 {
let st = CharStyle { font_family: FAMILY.into(), font_version: version.map(Into::into), size: 100.0, ..CharStyle::default() };
layout(db, &TextObject::point(Point::ZERO, "Hamburgefonstiv", st)).glyphs.iter().map(|g| g.advance).sum()
}
#[test]
fn a_version_is_found_by_its_version_string() {
let db = db();
let face = |v: Option<&str>| db.face_version(FAMILY, "Regular", v).expect("a face");
assert_eq!(db.face(FAMILY, "Regular").expect("a face").version, VERSIONS[1], "the first loaded stays the default");
assert_eq!(face(None).version, VERSIONS[1]);
assert_eq!(face(Some(VERSIONS[0])).version, VERSIONS[0]);
assert_eq!(face(Some("Version 9.000")).version, VERSIONS[1], "a version not installed: the default");
let all = db.versions(&face(None));
assert_eq!(all.iter().map(|f| f.version.as_str()).collect::<Vec<_>>(), [VERSIONS[1], VERSIONS[0]]);
}
#[test]
fn type_naming_a_version_is_set_in_it() {
let db = db();
let (newer, older) = (width(&db, None), width(&db, Some(VERSIONS[0])));
assert!((newer - older).abs() > 50.0, "the two versions set the line differently: {newer} vs {older}");
assert_eq!(width(&db, Some(VERSIONS[1])), newer);
}
+36
View File
@@ -0,0 +1,36 @@
# Craft-fonts faces the web (wasm32) build fetches from beside the wasm instead of embedding.
# One face per line: when | family | style (family and style as in craft-fonts' fonts/manifest.txt).
# startup = fetched before the app starts (fallbacks the first frame needs)
# background = fetched after the app starts (fonts users pick by name)
# Read by build.rs (the WEB_FONTS table) and `cargo xtask web-fonts` (copies the files, a Trunk hook).
startup | Noto Sans Arabic | Regular
background | Cairo | Regular
background | Tajawal | ExtraLight
background | Tajawal | Light
background | Tajawal | Regular
background | Tajawal | Medium
background | Tajawal | Bold
background | Tajawal | ExtraBold
background | Tajawal | Black
background | Almarai | Light
background | Almarai | Regular
background | Almarai | Bold
background | Almarai | ExtraBold
background | IBM Plex Sans Arabic | Thin
background | IBM Plex Sans Arabic | ExtraLight
background | IBM Plex Sans Arabic | Light
background | IBM Plex Sans Arabic | Regular
background | IBM Plex Sans Arabic | Medium
background | IBM Plex Sans Arabic | SemiBold
background | IBM Plex Sans Arabic | Bold
background | Noto Kufi Arabic | Regular
background | Amiri | Regular
background | Amiri | Italic
background | Amiri | Bold
background | Amiri | Bold Italic
background | Alexandria | Regular
background | Fustat | Regular
background | Reem Kufi | Regular
background | Aref Ruqaa | Regular
background | Aref Ruqaa | Bold
background | Gulzar | Regular
+1
View File
@@ -20,3 +20,4 @@ serde_json = { workspace = true }
[dev-dependencies]
image = { workspace = true }
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
+10 -4
View File
@@ -59,6 +59,9 @@ struct Preedit {
active: Option<Range<usize>>,
}
/// The edited text's layout, keyed by the font database's generation and the text.
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
#[derive(Default)]
pub struct TypeTool {
vertical: bool,
@@ -86,7 +89,7 @@ pub struct TypeTool {
clicks: (Option<Point>, u8),
/// Styled copy of the last copied range (pasting the same plain text keeps its styles).
clipboard: Vec<TextRun>,
cache: Mutex<Option<(TextObject, Arc<TextLayout>)>>,
cache: Mutex<Option<CachedLayout>>,
}
impl TypeTool {
@@ -106,16 +109,19 @@ impl TypeTool {
}
}
/// Layout of `t`, cached by content (overlays run every frame).
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available (the
/// web build adds fonts after startup).
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let fonts = FontDb::global().generation();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some((k, l)) = c.as_ref()
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
&& k == t
{
return l.clone();
}
let l = Arc::new(vectorcraft_text::layout(FontDb::global(), t));
*c = Some((t.clone(), l.clone()));
*c = Some(((fonts, t.clone()), l.clone()));
l
}
+12
View File
@@ -359,3 +359,15 @@ fn new_type_snaps_to_smart_guides() {
let area = json!({"width": 100.0, "height": 100.0});
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 100.0 && v["area"] == area)), "{acts:?}");
}
#[test]
fn layout_cache_follows_fonts_added_later() {
// The web build adds fonts after startup: a caret layout cached before one arrived is stale.
let (d, id, tool) = editing("abc");
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("not text") };
let before = tool.layout(t);
assert!(Arc::ptr_eq(&before, &tool.layout(t)), "cached while the fonts don't change");
let font = vectorcraft_text::test_fonts::variable_font().unwrap();
assert!(FontDb::global().add_font(font) > 0, "the test font is new to this process");
assert!(!Arc::ptr_eq(&before, &tool.layout(t)), "a font added after the layout must refresh it");
}
+4
View File
@@ -221,6 +221,10 @@
Auto Size New Area Type Ajustar automáticamente el tamaño del nuevo texto de área
Auto Spell Check Revisión ortográfica automática
Auto-Collapse Iconic Panels Contraer automáticamente los paneles de iconos
Ellipse Properties: Propiedades de la elipse:
Invert Pie Invertir sector
Pie End Angle: Ángulo final del sector:
Pie Start Angle: Ángulo inicial del sector:
Show Tool Group Labels Mostrar etiquetas de grupos de herramientas
Auto-Rotate (turn the paper to each artboard) Rotar automáticamente (girar el papel según cada mesa de trabajo)
Automatic flatness Lisura automática
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -222,6 +222,10 @@
Auto Size New Area Type Taille automatique du nouveau texte captif
Auto Spell Check Vérification orthographique automatique
Auto-Collapse Iconic Panels Réduire automatiquement les panneaux en icônes
Ellipse Properties: Propriétés de l’ellipse :
Invert Pie Inverser le secteur
Pie End Angle: Angle de fin du secteur :
Pie Start Angle: Angle de début du secteur :
Show Tool Group Labels Afficher les libellés des groupes d'outils
Auto-Rotate (turn the paper to each artboard) Rotation automatique (orienter le papier selon chaque plan de travail)
Automatic flatness Planéité automatique
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -221,6 +221,10 @@
Auto Size New Area Type Dimensionamento automatico del nuovo testo in area
Auto Spell Check Controllo ortografico automatico
Auto-Collapse Iconic Panels Comprimi automaticamente i pannelli a icona
Ellipse Properties: Proprietà dell’ellisse:
Invert Pie Inverti settore
Pie End Angle: Angolo finale del settore:
Pie Start Angle: Angolo iniziale del settore:
Show Tool Group Labels Mostra etichette dei gruppi di strumenti
Auto-Rotate (turn the paper to each artboard) Rotazione automatica (orienta la carta in base a ogni tavola da disegno)
Automatic flatness Planarità automatica
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -345,9 +345,13 @@
Appearance Attribute アピアランス属性
Apply 適用
Blending Mode 描画モード
Ellipse Properties: 楕円形のプロパティ:
Fill & Stroke 塗りと線
Fill Color カラー (塗り)
Invert Pie 扇形を反転
Opacity 不透明度
Pie End Angle: 扇形の終了角度:
Pie Start Angle: 扇形の開始角度:
Stroke Color カラー (線)
Stroke Weight 線幅
Graphic Style グラフィックスタイル
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -219,6 +219,10 @@
Auto Size New Area Type Автоматически подгонять размер нового текста в области
Auto Spell Check Автоматическая проверка орфографии
Auto-Collapse Iconic Panels Автоматически сворачивать значки панелей
Ellipse Properties: Свойства эллипса:
Invert Pie Инвертировать сектор
Pie End Angle: Конечный угол сектора:
Pie Start Angle: Начальный угол сектора:
Show Tool Group Labels Показывать названия групп инструментов
Auto-Rotate (turn the paper to each artboard) Автоповорот (поворачивать лист по каждой монтажной области)
Automatic flatness Автоматическая плоскостность
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -215,6 +215,10 @@
Auto Size New Area Type Автоматично підганяти розмір нового тексту в області
Auto Spell Check Автоматична перевірка орфографії
Auto-Collapse Iconic Panels Автоматично згортати значки панелей
Ellipse Properties: Властивості еліпса:
Invert Pie Інвертувати сектор
Pie End Angle: Кінцевий кут сектора:
Pie Start Angle: Початковий кут сектора:
Show Tool Group Labels Показувати назви груп інструментів
Auto-Rotate (turn the paper to each artboard) Автоматичний поворот (повертати аркуш відповідно до кожної монтажної області)
Automatic flatness Автоматична площинність
1 # Ukrainian (uk) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation.
215
216
217
218
219
220
221
222
223
224
+4
View File
@@ -204,6 +204,10 @@
Auto Size New Area Type 自动调整新区域文字大小
Auto Spell Check 自动拼写检查
Auto-Collapse Iconic Panels 自动折叠图标面板
Ellipse Properties: 椭圆属性:
Invert Pie 反转扇形
Pie End Angle: 扇形终止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 显示工具组标签
Auto-Rotate (turn the paper to each artboard) 自动旋转 (为每个画板转动纸张)
Automatic flatness 自动平滑度
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -215,6 +215,10 @@
Auto Size New Area Type 自動調整新區域文字大小
Auto Spell Check 自動拼字檢查
Auto-Collapse Iconic Panels 自動收合圖示面板
Ellipse Properties: 橢圓屬性:
Invert Pie 反轉扇形
Pie End Angle: 扇形終止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 顯示工具組標籤
Auto-Rotate (turn the paper to each artboard) 自動旋轉 (將紙張轉向各工作區域)
Automatic flatness 自動平滑度
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -468,6 +468,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
});
corner_radius_row(app, ui, &n, "radius");
}
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"),
_ => {}
}
}
+20
View File
@@ -192,6 +192,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
corner_radius_row(app, ui, &n, "xfp-radius");
}
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => {
widgets::subheader(ui, tl!("Ellipse Properties:"));
pie_rows(app, ui, *pie, "xfp-pie");
}
_ => {
widgets::subheader(ui, tl!("Shape Properties:"));
widgets::dim_label(ui, tl!(n.kind_label()));
@@ -211,6 +215,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// An ellipse's Pie Start and End Angle (degrees, counterclockwise from 3 o'clock; 0 to 360 is the
/// whole ellipse) and Invert Pie, which shows the other part of it (Transform and Properties panels).
pub(crate) fn pie_rows(app: &mut VectorcraftApp, ui: &mut Ui, pie: (f64, f64), id: &str) {
for (label, key, v) in [(tl!("Pie Start Angle:"), "pieStart", pie.0), (tl!("Pie End Angle:"), "pieEnd", pie.1)] {
ui.horizontal(|ui| {
widgets::dim_label(ui, label);
if let Some(a) = widgets::plain_field(ui, (id, key), v, "°", 1, 60.0) {
app.run("object.setLiveShape", json!({ key: a })).ok();
}
});
}
if widgets::flat_button(ui, tl!("Invert Pie"), 90.0).clicked() {
app.run("object.setLiveShape", json!({"invertPie": true})).ok();
}
}
/// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a
/// popover under it, as the Stroke link does with the Stroke panel.
pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) {
+24
View File
@@ -577,4 +577,28 @@ mod tests {
assert!(PREF_SPECS.iter().any(|s| s.category == *c));
}
}
/// Preferences › Hyphenation › Exceptions (#394): OK on the dialog pushes the list into the
/// hyphenator the same way `prefs.set` does (no need to drive the live Preferences window).
#[test]
fn hyphenation_exceptions_dialog_ok_reaches_the_hyphenator() {
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
let mut a = app();
open(&mut a, Some("Hyphenation"));
let d = a.ui.dialog.as_mut().unwrap();
assert_eq!(d.str("__category"), "Hyphenation");
d.fields.insert("hyphenationExceptions".into(), json!("typography, hap-pen"));
confirm(&mut a).unwrap();
assert!(a.ui.dialog.is_none());
assert_eq!(a.session.prefs.hyphenation_exceptions, "typography, hap-pen");
assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen");
assert!(vectorcraft_text::hyphen::hyphen_points("typography").is_empty());
assert_eq!(vectorcraft_text::hyphen::hyphen_points("happen"), vec![3]);
}
}
+13 -1
View File
@@ -785,8 +785,20 @@ pub fn menu_scroll<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R
/// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the
/// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is
/// over it.
/// Its scroll bar stays a slim line at the edge, even while scrolled or dragged, so it never covers
/// the buttons (#808).
pub fn strip_scroll<R>(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner
ui.scope(|ui| {
ui.spacing_mut().scroll = egui::style::ScrollStyle {
bar_width: 4.0,
floating_width: 2.0,
bar_inner_margin: 0.0,
bar_outer_margin: 1.0,
..egui::style::ScrollStyle::floating()
};
egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner
})
.inner
}
/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a
+26
View File
@@ -12,6 +12,13 @@ trunk build --release # writes ../../dist/web (index.html, .js glue
trunk serve --release # dev server on http://127.0.0.1:8766
```
What the web build needs: `trunk`, the `wasm32-unknown-unknown` target and `cargo`, nothing else (no
POSIX shell, so it works the same on Windows). Optionally, `CRAFT_FONTS_DIR` set to an absolute
craft-fonts checkout adds the fonts the site serves beside the wasm: Trunk's `post_build` hook runs
`cargo xtask web-fonts`, which copies them into `dist/web/fonts/` (it does nothing without
`CRAFT_FONTS_DIR`, and skips with a warning any listed face an older checkout lacks). See
[Arabic fonts and the web build](#arabic-fonts-and-the-web-build).
Any static file server works for `dist/web`, for example `python3 -m http.server 8766` inside that directory. The release `.wasm` is about 17.5 MB, or 7.1 MB gzipped, so serve it with compression.
URL flag: `?webgl` forces the WebGL2 backend.
@@ -124,6 +131,25 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci # also runs the Japanese-
CI job and every release job (`release.yml`) check craft-fonts out at a pinned commit; release
packages carry each embedded font's `OFL-<family>.txt`.
### Arabic fonts and the web build
Desktop builds load craft-fonts' Arabic families into the document font database (Noto Sans
Arabic first, the fallback for Arabic text; the others are picked by name).
The web build doesn't embed them (wasm size). Instead `crates/text/web-fonts.txt` lists the faces
it fetches from beside the wasm, `startup` (before the app starts) or `background` (after):
- `crates/text/build.rs` turns the list into `vectorcraft_text::WEB_FONTS` (URL
`fonts/<sha16>/<file>` and the SRI hash, from the craft-fonts manifest).
- `cargo xtask web-fonts` (`xtask/src/web_fonts.rs`), the Trunk `post_build` hook, copies the files
there, checking each one's SHA-256. A listed face the checkout lacks is skipped with a warning, as
in `build.rs`, so an older craft-fonts just means fewer fonts.
- `apps/vectorcraft-web/src/fonts.rs` fetches and registers them, logging `web font …` for any
that fail.
Try it: `cd apps/vectorcraft-web && CRAFT_FONTS_DIR=$PWD/../../../craft-fonts trunk serve`. To serve
another craft-fonts face on the web, add a line to `web-fonts.txt`.
## Localisation
Strings in code stay English and are the default lookup keys. `crates/ui-egui/src/i18n` maps them to display
+3 -2
View File
@@ -1,13 +1,14 @@
# Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional.
AddType application/wasm .wasm
AddType font/ttf .ttf
AddType text/javascript .js
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/wasm text/javascript text/html
AddOutputFilterByType DEFLATE application/wasm text/javascript text/html font/ttf
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(wasm|js)$">
<FilesMatch "\.(wasm|js|ttf)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "^index\.html$">
+13
View File
@@ -50,6 +50,19 @@ location /vectorcraft/ {
Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
## Fonts folder
Builds made with craft-fonts (`CRAFT_FONTS_DIR`, all releases) have a `fonts/` folder beside the
wasm: the Arabic fonts the app fetches instead of embedding (about 8 MB, each next to its OFL
licence). Serve it with the rest of the site. Noto Sans Arabic is fetched before the app starts;
the other families load in the background and appear in the font menus as they arrive.
- Serve `.ttf` as `font/ttf` (the browser checks each file's SHA-256, so a wrong type or a
rewritten file makes the app skip that font and log `web font …` in the console).
- The folder names are the files' SHA-256 prefixes, so `fonts/*` can be cached forever
(`Cache-Control: public, max-age=31536000, immutable`); `_headers` and `.htaccess` do this.
- Without the folder the app still runs, but Arabic text shows as boxes.
## Embedding in a page (iframe)
```html
+4
View File
@@ -11,3 +11,7 @@
/index.html
Cache-Control: no-cache
/fonts/*
Content-Type: font/ttf
Cache-Control: public, max-age=31536000, immutable
+4
View File
@@ -12,6 +12,7 @@ mod layers;
mod sha256;
mod stats;
mod version;
mod web_fonts;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -32,6 +33,8 @@ commands:
ico <out.ico> <png>...
pack PNGs into a Windows .ico (used by packaging/icons.sh)
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk
post_build hook; a no-op without CRAFT_FONTS_DIR)
";
fn main() -> ExitCode {
@@ -49,6 +52,7 @@ fn main() -> ExitCode {
Some("ico") => ico::run(&rest),
Some("version") => version::run(&root(), &rest),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("web-fonts") => web_fonts::run(&root()),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())
+119
View File
@@ -0,0 +1,119 @@
//! `cargo xtask web-fonts`: the Trunk `post_build` hook (`apps/*-web/Trunk.toml`). Copies the
//! craft-fonts faces listed in `crates/text/web-fonts.txt` from `$CRAFT_FONTS_DIR` into the site,
//! each at `fonts/<first 16 hex digits of its SHA-256>/<file>` with its licence beside it: the URLs
//! `crates/text/build.rs` puts in `WEB_FONTS` (both use `crates/text/build/web_fonts.rs`).
//!
//! A no-op without `CRAFT_FONTS_DIR`. A listed face the checkout lacks (an older craft-fonts) is
//! skipped with a warning, as `build.rs` does; a file whose SHA-256 doesn't match the manifest is an
//! error (a corrupt checkout). Rust rather than a shell script, so the hook runs wherever cargo does.
use std::path::{Path, PathBuf};
use crate::sha256;
#[allow(dead_code)] // build.rs uses the rest (sri, base64)
#[path = "../../crates/text/build/web_fonts.rs"]
mod list;
/// The hook: reads `CRAFT_FONTS_DIR` and Trunk's `TRUNK_STAGING_DIR`.
pub fn run(root: &Path) -> Result<(), String> {
let var = |name| std::env::var_os(name).filter(|v| !v.is_empty()).map(PathBuf::from);
run_with(root, var("CRAFT_FONTS_DIR"), var("TRUNK_STAGING_DIR"))
}
fn run_with(root: &Path, craft_fonts: Option<PathBuf>, staging: Option<PathBuf>) -> Result<(), String> {
let Some(dir) = craft_fonts else { return Ok(()) };
// Relative like build.rs: from the workspace root, not the directory Trunk runs hooks in.
let dir = if dir.is_absolute() { dir } else { root.join(dir) };
let staging = staging.ok_or("TRUNK_STAGING_DIR is unset: run this as the Trunk post_build hook")?;
let (copied, skipped) = copy(&root.join("crates/text/web-fonts.txt"), &dir, &staging)?;
if !skipped.is_empty() {
eprintln!("warning: web build won't fetch {} (not in CRAFT_FONTS_DIR={}; an older craft-fonts?)", skipped.join(", "), dir.display());
}
println!("web-fonts: copied {copied} font(s) into {}", staging.join("fonts").display());
Ok(())
}
/// Copy the faces `list_path` names from the craft-fonts checkout `dir` into `site` (at their
/// `fonts/…` URLs). Returns how many were copied and the listed faces the checkout lacks.
fn copy(list_path: &Path, dir: &Path, site: &Path) -> Result<(usize, Vec<String>), String> {
let read = |p: &Path| std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()));
let list = read(list_path)?;
let manifest = read(&dir.join("fonts/manifest.txt"))?;
let rows: Vec<Vec<&str>> =
manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect();
let (mut copied, mut skipped) = (0, Vec::new());
for e in list::parse_list(&list)? {
let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else {
skipped.push(format!("{} {}", e.family, e.style));
continue;
};
let (Some(file), Some(licence), Some(sha)) = (row.get(2), row.get(5), row.get(6)) else {
return Err(format!("malformed manifest row for {} {}", e.family, e.style));
};
let bytes = std::fs::read(dir.join(file)).map_err(|err| format!("{file}: {err}"))?;
let got = sha256::hex(&bytes);
if got != *sha {
return Err(format!("{file}: sha256 {got}, manifest says {sha}"));
}
let dest = site.join(list::url(sha, file));
let folder = dest.parent().ok_or_else(|| format!("{}: no parent folder", dest.display()))?;
std::fs::create_dir_all(folder).map_err(|err| format!("{}: {err}", folder.display()))?;
std::fs::write(&dest, &bytes).map_err(|err| format!("{}: {err}", dest.display()))?;
let licence_name = Path::new(licence).file_name().ok_or_else(|| format!("{licence}: not a file"))?;
std::fs::copy(dir.join(licence), folder.join(licence_name)).map_err(|err| format!("{licence}: {err}"))?;
copied += 1;
}
Ok((copied, skipped))
}
#[cfg(test)]
mod tests {
use super::*;
/// A fake craft-fonts checkout with one face, and a list naming it plus one it lacks.
fn fixture(name: &str, manifest_sha: Option<&str>) -> (PathBuf, PathBuf, PathBuf, String) {
let base = std::env::temp_dir().join(format!("xtask-web-fonts-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let dir = base.join("craft-fonts");
std::fs::create_dir_all(dir.join("fonts/a")).unwrap();
std::fs::write(dir.join("fonts/a/A-Regular.ttf"), b"font bytes").unwrap();
std::fs::write(dir.join("fonts/a/OFL.txt"), b"licence").unwrap();
let sha = sha256::hex(b"font bytes");
let row_sha = manifest_sha.map_or(sha.clone(), str::to_string);
std::fs::write(
dir.join("fonts/manifest.txt"),
format!("# family | style | file | scripts | licence | licence file | sha256 | source\nA Sans | Regular | fonts/a/A-Regular.ttf | Arab | OFL-1.1 | fonts/a/OFL.txt | {row_sha} | https://example.invalid\n"),
)
.unwrap();
let list = base.join("web-fonts.txt");
std::fs::write(&list, "startup | A Sans | Regular\nbackground | Missing Sans | Bold\n").unwrap();
(list, dir, base.join("site"), sha)
}
#[test]
fn copies_listed_faces_with_their_licence_to_their_urls() {
let (list, dir, site, sha) = fixture("copy", None);
let (copied, skipped) = copy(&list, &dir, &site).unwrap();
assert_eq!(copied, 1);
let at = site.join("fonts").join(&sha[..16]);
assert_eq!(std::fs::read(at.join("A-Regular.ttf")).unwrap(), b"font bytes");
assert_eq!(std::fs::read(at.join("OFL.txt")).unwrap(), b"licence");
assert_eq!(format!("fonts/{}/A-Regular.ttf", &sha[..16]), list::url(&sha, "fonts/a/A-Regular.ttf"));
assert_eq!(skipped, ["Missing Sans Bold"], "a face the checkout lacks is skipped, not an error");
}
#[test]
fn a_file_that_does_not_match_its_manifest_hash_is_an_error() {
let (list, dir, site, _) = fixture("mismatch", Some(&"0".repeat(64)));
let e = copy(&list, &dir, &site).unwrap_err();
assert!(e.contains("A-Regular.ttf") && e.contains("sha256"), "{e}");
}
#[test]
fn without_craft_fonts_the_hook_does_nothing() {
let (_, _, site, _) = fixture("noop", None);
run_with(Path::new("/nonexistent"), None, None).unwrap();
assert!(!site.exists());
}
}