Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4181755db6 | ||
|
|
c93d4ad995 | ||
|
|
4cf912fa94 | ||
|
|
78f0297127 | ||
|
|
d857d2c0b7 | ||
|
|
9df597a46d | ||
|
|
4c175ed099 | ||
|
|
a262ee5c9e | ||
|
|
445bceb681 | ||
|
|
4f9b292213 | ||
|
|
c9ba5db4fb | ||
|
|
562e09707a |
No files matched your search
+4
-4
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -416,6 +416,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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
@@ -324,6 +327,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 +1039,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);
|
||||
@@ -1046,6 +1054,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
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,
|
||||
@@ -1150,6 +1159,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),
|
||||
@@ -1242,8 +1253,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)) {
|
||||
@@ -1267,7 +1283,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)) {
|
||||
@@ -1329,6 +1352,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();
|
||||
@@ -1520,6 +1545,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 {
|
||||
|
||||
@@ -1,12 +1,109 @@
|
||||
//! 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. Only the matched [`Exception`] is cloned, never
|
||||
/// the whole list.
|
||||
#[cfg(not(test))]
|
||||
fn exception_for(word: &str) -> Option<Exception> {
|
||||
store().map.get(&normalize_word(word)).cloned()
|
||||
}
|
||||
|
||||
/// Unit tests read only their own thread's list: tests running in parallel set the process-wide
|
||||
/// one.
|
||||
#[cfg(test)]
|
||||
fn exception_for(word: &str) -> Option<Exception> {
|
||||
TEST_OVERRIDE.with(|c| c.borrow().as_ref().and_then(|e| e.map.get(&normalize_word(word)).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 +122,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 +173,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());
|
||||
}
|
||||
}
|
||||
@@ -263,7 +263,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 +1393,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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ 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 vectorcraft_doc::TextObject;
|
||||
@@ -522,6 +523,8 @@ 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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,24 +1,110 @@
|
||||
//! The All Tools drawer: every tool as a button; picking one selects it and closes the drawer.
|
||||
//! The All Tools drawer: every tool with its icon and name, under the headings of the toolbar's
|
||||
//! groups (Select, Shapes, Draw, Modify, Type, Navigate, Color) and of the tools the Basic toolbar
|
||||
//! leaves out (#807). The active tool is highlighted; picking one selects it and closes the drawer.
|
||||
|
||||
use egui::{Sense, vec2};
|
||||
use vectorcraft_tools::{TOOL_GROUPS, ToolInfo};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, icons, widgets};
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: 560.0, ..DialogSpec::FORM };
|
||||
|
||||
/// A tool's cell: its icon and name.
|
||||
const CELL: egui::Vec2 = vec2(176.0, 28.0);
|
||||
const ICON: f32 = 20.0;
|
||||
|
||||
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
|
||||
/// Advanced toolbar shows.
|
||||
fn heading(id: &str) -> &'static str {
|
||||
if let Some((cat, _)) = crate::toolbar::BASIC.iter().find(|(_, slots)| slots.iter().any(|s| s.contains(&id))) {
|
||||
return cat;
|
||||
}
|
||||
match id {
|
||||
_ if id.starts_with("symbol") => "Symbols",
|
||||
_ if id.ends_with("Graph") => "Graph",
|
||||
_ if id.starts_with("perspective") => "Perspective",
|
||||
"artboard" => "Artboards",
|
||||
"slice" | "sliceSelection" => "Slices",
|
||||
_ => "More",
|
||||
}
|
||||
}
|
||||
|
||||
/// The headings in order, each with its tools in the toolbar's order.
|
||||
fn sections() -> Vec<(&'static str, Vec<&'static ToolInfo>)> {
|
||||
let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect();
|
||||
for tool in TOOL_GROUPS.iter().flat_map(|g| g.iter()) {
|
||||
let h = heading(tool.id);
|
||||
match out.iter_mut().find(|(c, _)| *c == h) {
|
||||
Some((_, tools)) => tools.push(tool),
|
||||
None => out.push((h, vec![tool])),
|
||||
}
|
||||
}
|
||||
out.retain(|(_, tools)| !tools.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool {
|
||||
let mut picked = false;
|
||||
for g in vectorcraft_tools::TOOL_GROUPS {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for tool in g.iter() {
|
||||
let shown = tl!(tool.label);
|
||||
let shown = if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown };
|
||||
if ui.button(shown).clicked() {
|
||||
app.select_tool(tool.id);
|
||||
picked = true;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let active = app.session.tool_id();
|
||||
let mut picked = None;
|
||||
let room = (ui.ctx().content_rect().height() - 180.0).max(200.0);
|
||||
egui::ScrollArea::vertical().max_height(room).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (cat, tools) in sections() {
|
||||
widgets::subheader(ui, tl!(cat));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
for tool in tools {
|
||||
let (rect, resp) = ui.allocate_exact_size(CELL, Sense::click());
|
||||
let on = tool.id == active;
|
||||
if on {
|
||||
ui.painter().rect_filled(rect, 3.0, t.selection);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
let icon = egui::Rect::from_center_size(rect.left_center() + vec2(6.0 + ICON / 2.0, 0.0), vec2(ICON, ICON));
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), icon, if on { t.text_strong } else { t.icon });
|
||||
let name = tl!(tool.label);
|
||||
let name = if crate::i18n::current() == crate::i18n::Lang::EN { name.trim_end_matches(" Tool") } else { name };
|
||||
let text_at = egui::pos2(icon.right() + 8.0, rect.center().y);
|
||||
ui.painter().with_clip_rect(rect).text(
|
||||
text_at,
|
||||
egui::Align2::LEFT_CENTER,
|
||||
name,
|
||||
egui::FontId::proportional(12.0),
|
||||
if on { t.text_strong } else { t.text },
|
||||
);
|
||||
if resp.on_hover_text(crate::toolbar::tip(tool)).clicked() {
|
||||
picked = Some(tool.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
if let Some(id) = picked {
|
||||
app.select_tool(id);
|
||||
}
|
||||
picked.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order.
|
||||
#[test]
|
||||
fn every_tool_is_listed_once_under_a_heading() {
|
||||
let s = sections();
|
||||
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
|
||||
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
|
||||
assert_eq!(listed.len(), all.len());
|
||||
assert!(all.iter().all(|id| listed.contains(id)));
|
||||
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]);
|
||||
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
|
||||
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
|
||||
}
|
||||
picked
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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 Автоматична площинність
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[
|
||||
("Color", &[&["gradient", "mesh"], &["eyedropper", "measure"]]),
|
||||
];
|
||||
|
||||
fn tip(t: &ToolInfo) -> String {
|
||||
pub(crate) fn tip(t: &ToolInfo) -> String {
|
||||
match crate::shortcut_editor::tool_shortcut(t.id) {
|
||||
Some(s) => format!("{} ({})", tl!(t.label), s),
|
||||
None => tl!(t.label).to_string(),
|
||||
|
||||
Reference in new issue
Block a user