mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-17 12:47:01 -04:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daaeb45565 | ||
|
|
af555713d5 | ||
|
|
c1c677bc76 | ||
|
|
a86cb76d52 | ||
|
|
2efd084b97 | ||
|
|
665373b699 | ||
|
|
4bf8b3314a | ||
|
|
0bb793c079 | ||
|
|
68343ab66a | ||
|
|
4e620ced97 | ||
|
|
15f55f5569 | ||
|
|
fe3e8f65a5 | ||
|
|
5ad04faff3 | ||
|
|
80b9753866 | ||
|
|
4712fb2c2f | ||
|
|
1e06c82800 | ||
|
|
17a602d1dc | ||
|
|
a2e2eba2e2 | ||
|
|
0594ffe30f | ||
|
|
abb7c1351b | ||
|
|
c9f0ed14f0 | ||
|
|
8c10185ef9 | ||
|
|
b9ff56e403 | ||
|
|
f38f6fafc3 | ||
|
|
88567ee46c | ||
|
|
04b0be2c9f | ||
|
|
f5511df65f | ||
|
|
800db5b29d | ||
|
|
84ccee71ef | ||
|
|
fa46998006 |
No files matched your search
+2
-2
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 33/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 33/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -134,7 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
|
||||
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
|
||||
|
||||
@@ -875,6 +875,8 @@ fn main() -> std::process::ExitCode {
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
|
||||
app.session.style_libraries.set_user_dir(styles);
|
||||
// With the User Defined libraries listed, the persistent library panels reopen.
|
||||
vectorcraft_ui_egui::panels::library_panel::restore(&mut app);
|
||||
// So do the Libraries panel's libraries; runs without preferences (agents' test
|
||||
// runs) keep theirs for the session only, never touching the user's.
|
||||
if prefs_enabled() {
|
||||
|
||||
@@ -32,6 +32,11 @@ pub struct CharStyle {
|
||||
/// 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>,
|
||||
/// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag
|
||||
/// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of
|
||||
/// each axis they name; the other axes keep the style's settings. Empty: the style as it is.
|
||||
#[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub font_axes: std::collections::BTreeMap<String, f64>,
|
||||
/// Size in points.
|
||||
pub size: f64,
|
||||
/// Leading in points; None = Auto (120% of size).
|
||||
@@ -231,6 +236,7 @@ impl Default for CharStyle {
|
||||
font_family: "Source Sans 3".into(),
|
||||
font_style: "Regular".into(),
|
||||
font_version: None,
|
||||
font_axes: std::collections::BTreeMap::new(),
|
||||
size: 12.0,
|
||||
leading: None,
|
||||
tracking: 0.0,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, Rasterize, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
|
||||
//! Unsharp Mask and Glowing Edges) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
@@ -68,7 +68,7 @@ pub use group::{
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
@@ -150,6 +150,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
"sketch.halftonePattern" => always(&["size"]),
|
||||
RASTERIZE => always(&["addAround"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -635,6 +636,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it",
|
||||
json!({}),
|
||||
));
|
||||
v.push(r(
|
||||
RASTERIZE,
|
||||
"Rasterize…",
|
||||
&["Effect"],
|
||||
"{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes",
|
||||
json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}),
|
||||
));
|
||||
v
|
||||
}
|
||||
|
||||
@@ -784,7 +792,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) {
|
||||
|
||||
/// Is `id` a raster (painted) effect?
|
||||
pub fn is_raster(id: &str) -> bool {
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE)
|
||||
|| PIXEL_EFFECTS.contains(&id)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
//! Raster (painted) effects: descriptions for the renderer.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_doc::color::{BlendMode, Color};
|
||||
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
|
||||
use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings};
|
||||
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::merged_params;
|
||||
use crate::pixel::{self, PixelFx};
|
||||
use crate::pixel::{self, PixelFx, PixelSpace};
|
||||
use crate::util::*;
|
||||
|
||||
/// Effect › Rasterize's id.
|
||||
pub const RASTERIZE: &str = "rasterize";
|
||||
|
||||
/// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses
|
||||
/// a Gaussian with σ = blur / 2 (so the visible spread is about 1.5 × blur).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -48,6 +52,8 @@ pub enum RasterFx {
|
||||
},
|
||||
/// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…).
|
||||
Pixel(PixelFx),
|
||||
/// Effect › Rasterize: the object drawn as an image of square pixels.
|
||||
Rasterize(Rasterize),
|
||||
}
|
||||
|
||||
impl RasterFx {
|
||||
@@ -71,6 +77,278 @@ impl RasterFx {
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
|
||||
RasterFx::GaussianBlur { radius } => 1.5 * radius,
|
||||
RasterFx::Pixel(p) => p.outset(bounds),
|
||||
RasterFx::Rasterize(r) => r.outset(),
|
||||
}
|
||||
}
|
||||
/// This effect with a Rasterize that takes the document's resolution at `ppi` ([`Rasterize::at_document_ppi`]).
|
||||
pub fn at_document_ppi(&self, ppi: f64) -> RasterFx {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => RasterFx::Rasterize(r.at_document_ppi(ppi)),
|
||||
fx => fx.clone(),
|
||||
}
|
||||
}
|
||||
/// What this effect paints over when what it paints around covers `bounds`: Rasterize's image
|
||||
/// ([`Rasterize::image`], `line` the document's raster grid), `bounds` grown by
|
||||
/// [`Self::outset`] for the others.
|
||||
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => r.image(bounds, line),
|
||||
fx => {
|
||||
let o = fx.outset(bounds);
|
||||
bounds.inflate(o, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect › Rasterize's options, read from its parameters. The image covers the object's bounds
|
||||
/// (as far as the effects before it spread the object) and `add_around` points around them, in
|
||||
/// pixels `72 / ppi` points wide whose first one starts at that area's top-left corner, as Object
|
||||
/// › Rasterize makes its image.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Rasterize {
|
||||
/// Pixels per inch (1 to 2400); `None` takes Document Raster Effects Settings › Resolution.
|
||||
pub ppi: Option<f64>,
|
||||
/// The image's color model.
|
||||
pub color_model: RasterColorModel,
|
||||
/// A white background: the image is opaque. Otherwise it keeps its transparency.
|
||||
pub white: bool,
|
||||
/// Smooth edges (Art Optimized or Type Optimized). Off, each pixel takes the art at its center.
|
||||
pub anti_alias: bool,
|
||||
/// Type Optimized: smooth edges with type fitted to the pixels.
|
||||
pub hinted: bool,
|
||||
/// The image is clipped to the art's outline.
|
||||
pub clipping_mask: bool,
|
||||
/// Room around the art, in points (0 to [`MAX_ADD_AROUND`]).
|
||||
pub add_around: f64,
|
||||
}
|
||||
|
||||
impl Rasterize {
|
||||
/// The options in the effect's parameters `p` (defaults merged in).
|
||||
fn parse(p: &Value) -> Self {
|
||||
let ppi = (!flag(p, "useDocumentResolution", true)).then(|| num(p, "resolution", 72.0).clamp(1.0, 2400.0));
|
||||
let color_model = match text(p, "colorModel", "document").to_ascii_lowercase().as_str() {
|
||||
"grayscale" | "gray" => RasterColorModel::Grayscale,
|
||||
"bitmap" => RasterColorModel::Bitmap,
|
||||
_ => RasterColorModel::Document,
|
||||
};
|
||||
Self {
|
||||
ppi,
|
||||
color_model,
|
||||
white: text(p, "background", "transparent").eq_ignore_ascii_case("white"),
|
||||
anti_alias: !text(p, "antiAlias", "art").eq_ignore_ascii_case("none"),
|
||||
hinted: text(p, "antiAlias", "art").eq_ignore_ascii_case("type"),
|
||||
clipping_mask: flag(p, "clippingMask", false),
|
||||
add_around: num(p, "addAround", 0.0).clamp(0.0, MAX_ADD_AROUND),
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of a pixel of the image in document units: 72 / `ppi`, or `line` (72 / the
|
||||
/// document's raster effects resolution) when it takes the document's.
|
||||
pub fn cell(&self, line: f64) -> f64 {
|
||||
self.ppi.map_or(line, |ppi| 72.0 / ppi)
|
||||
}
|
||||
|
||||
/// These options at `ppi` when they take the document's resolution.
|
||||
pub fn at_document_ppi(self, ppi: f64) -> Self {
|
||||
Self { ppi: self.ppi.or(Some(ppi)), ..self }
|
||||
}
|
||||
|
||||
/// How far beyond the object's bounds the image reaches: the room around it and, at a known
|
||||
/// resolution, one pixel more for the last column and row, which are whole pixels.
|
||||
pub fn outset(&self) -> f64 {
|
||||
self.add_around + self.ppi.map_or(0.0, |ppi| 72.0 / ppi)
|
||||
}
|
||||
|
||||
/// How far (document units) from a pixel the content that decides it lies: across its pixel
|
||||
/// of the image (72 points when the resolution is the document's, not known here).
|
||||
pub fn reach(&self) -> f64 {
|
||||
self.cell(72.0) * std::f64::consts::SQRT_2
|
||||
}
|
||||
|
||||
/// The image of art over `bounds`: `bounds` and the room around them, in whole pixels
|
||||
/// ([`Self::cell`] of `line`) from that area's top-left corner.
|
||||
pub fn image(&self, bounds: Rect, line: f64) -> Rect {
|
||||
let cell = self.cell(line);
|
||||
let area = bounds.inflate(self.add_around, self.add_around);
|
||||
let (cols, rows) = ((area.width() / cell).ceil().max(1.0), (area.height() / cell).ceil().max(1.0));
|
||||
Rect::new(area.x0, area.y0, area.x0 + cols * cell, area.y0 + rows * cell)
|
||||
}
|
||||
|
||||
/// Give `pixels` (premultiplied RGBA8), the art drawn on the image's own grid, the background
|
||||
/// and then the color model, as Object › Rasterize does.
|
||||
pub fn finish(&self, pixels: &mut [u8]) {
|
||||
for p in pixels.as_chunks_mut::<4>().0 {
|
||||
if self.white {
|
||||
let under = 255 - p[3];
|
||||
*p = p.map(|c| c.saturating_add(under));
|
||||
}
|
||||
}
|
||||
RasterEffectsSettings { color_model: self.color_model, ..Default::default() }.finish_pixels(pixels);
|
||||
}
|
||||
|
||||
/// Draw `image` into `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as
|
||||
/// `space` says: `image` is `cols` × `rows` premultiplied RGBA8 pixels covering `rect` in the
|
||||
/// document. In a view that isn't turned, each pixel of `data` takes the mean of the image over
|
||||
/// its area (transparent outside the image); otherwise the mean of the image at up to 8 × 8
|
||||
/// points across it. Does nothing when the sizes don't match.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compose(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, image: &[u8], cols: usize, rows: usize, rect: Rect) {
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
let (src, src_rest) = image.as_chunks::<4>();
|
||||
let fits = |n: usize, w: usize, h: usize, rest: usize| w > 0 && h > 0 && rest == 0 && w.checked_mul(h) == Some(n);
|
||||
if !fits(px.len(), w, h, rest.len()) || !fits(src.len(), cols, rows, src_rest.len()) || !(rect.width() > 0.0 && rect.height() > 0.0) {
|
||||
return;
|
||||
}
|
||||
let (sx, sy) = (cols as f64 / rect.width(), rows as f64 / rect.height());
|
||||
// Document → image pixels.
|
||||
let to_image = |p: Point| ((p.x - rect.x0) * sx, (p.y - rect.y0) * sy);
|
||||
let at = |i: f64, j: f64| {
|
||||
(i >= 0.0 && j >= 0.0 && i < cols as f64 && j < rows as f64).then(|| src.get(j as usize * cols + i as usize).copied()).flatten()
|
||||
};
|
||||
let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0);
|
||||
// Points across a raster pixel in a turned view: about one an image pixel.
|
||||
let n = (space.px * sx.max(sy)).ceil().clamp(1.0, 8.0) as usize;
|
||||
for (k, p) in px.iter_mut().enumerate() {
|
||||
let (x, y) = ((k % w) as f64, (k / w) as f64);
|
||||
let mut acc = [0.0f64; 4];
|
||||
let weight = if straight {
|
||||
let (a, b) = (to_image(space.to_doc * Point::new(x, y)), to_image(space.to_doc * Point::new(x + 1.0, y + 1.0)));
|
||||
let (u0, u1, v0, v1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1));
|
||||
let (i0, i1) = (u0.floor().max(0.0), u1.ceil().min(cols as f64));
|
||||
let (j0, j1) = (v0.floor().max(0.0), v1.ceil().min(rows as f64));
|
||||
let mut j = j0;
|
||||
while j < j1 {
|
||||
let mut i = i0;
|
||||
while i < i1 {
|
||||
let share = (u1.min(i + 1.0) - u0.max(i)).max(0.0) * (v1.min(j + 1.0) - v0.max(j)).max(0.0);
|
||||
if let Some(q) = at(i, j) {
|
||||
for (s, v) in acc.iter_mut().zip(q) {
|
||||
*s += share * f64::from(v);
|
||||
}
|
||||
}
|
||||
i += 1.0;
|
||||
}
|
||||
j += 1.0;
|
||||
}
|
||||
(u1 - u0) * (v1 - v0)
|
||||
} else {
|
||||
for t in 0..n * n {
|
||||
let (fx, fy) = (((t % n) as f64 + 0.5) / n as f64, ((t / n) as f64 + 0.5) / n as f64);
|
||||
let (i, j) = to_image(space.to_doc * Point::new(x + fx, y + fy));
|
||||
if let Some(q) = at(i.floor(), j.floor()) {
|
||||
for (s, v) in acc.iter_mut().zip(q) {
|
||||
*s += f64::from(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
(n * n) as f64
|
||||
};
|
||||
*p = if weight > 0.0 { acc.map(|s| (s / weight).round().clamp(0.0, 255.0) as u8) } else { [0; 4] };
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn `data`, `w` × `h` premultiplied RGBA8 pixels of the object lying in the document as
|
||||
/// `space` says, into its image over `bounds` (what the object covers after the effects before
|
||||
/// this one), when the image is too large to draw on its own grid. Each pixel of the image more
|
||||
/// than a raster pixel wide takes the mean of the raster pixels whose centers lie in it
|
||||
/// (anti-aliased) or the raster pixel at its center (not anti-aliased). Smaller pixels are left
|
||||
/// as drawn. Then the background and the color model apply as Object › Rasterize applies them.
|
||||
/// What lies outside the image becomes transparent, and in a view that isn't turned the raster
|
||||
/// pixels along its edges keep the share of them it covers. Does nothing when the sizes don't
|
||||
/// match. The renderer draws the content with hard edges when it goes into Rasterize with
|
||||
/// anti-aliasing off and image pixels at least a raster pixel wide.
|
||||
pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, bounds: Rect) {
|
||||
let cell = self.cell(space.line);
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
if w == 0 || h == 0 || !rest.is_empty() || w.checked_mul(h) != Some(px.len()) || !(cell.is_finite() && cell > 0.0 && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let area = self.image(bounds, space.line);
|
||||
let (cols, rows) = ((area.width() / cell).round().max(1.0), (area.height() / cell).round().max(1.0));
|
||||
// The pixel of the image (column, row) that a raster pixel's center lies in.
|
||||
let pixel_at = |x: usize, y: usize| {
|
||||
let p = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let (c, r) = (((p.x - area.x0) / cell).floor(), ((p.y - area.y0) / cell).floor());
|
||||
(c >= 0.0 && r >= 0.0 && c < cols && r < rows).then_some((c, r))
|
||||
};
|
||||
let wide = cell / space.px;
|
||||
// The image's pixels the raster covers: columns c0..c1, rows r0..r1.
|
||||
let seen = space.to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
let (c0, c1) = (((seen.x0 - area.x0) / cell).floor().clamp(0.0, cols), ((seen.x1 - area.x0) / cell).ceil().clamp(0.0, cols));
|
||||
let (r0, r1) = (((seen.y0 - area.y0) / cell).floor().clamp(0.0, rows), ((seen.y1 - area.y0) / cell).ceil().clamp(0.0, rows));
|
||||
let span = (c1 - c0) as usize;
|
||||
let slot = |(c, r): (f64, f64)| ((r - r0) as usize).saturating_mul(span).saturating_add((c - c0) as usize);
|
||||
// Anti-aliased: the sums of each pixel's raster pixels (premultiplied) and their count,
|
||||
// which saturate past 16 million raster pixels to an image pixel. They are kept for the
|
||||
// whole grid when it has no more pixels than the raster, else for the pixels the raster
|
||||
// reaches (a turned view of a narrow raster).
|
||||
let grid = span.saturating_mul((r1 - r0) as usize);
|
||||
let whole = grid <= px.len();
|
||||
let mut dense: Vec<[u32; 5]> = vec![];
|
||||
let mut sparse: std::collections::HashMap<usize, [u32; 5]> = std::collections::HashMap::new();
|
||||
if self.anti_alias && wide > 1.0 {
|
||||
if whole {
|
||||
dense = vec![[0; 5]; grid];
|
||||
}
|
||||
for (i, p) in px.iter().enumerate() {
|
||||
let Some(k) = pixel_at(i % w, i / w).map(slot) else { continue };
|
||||
let s = if whole { dense.get_mut(k) } else { Some(sparse.entry(k).or_default()) };
|
||||
if let Some(s) = s {
|
||||
for (acc, v) in s.iter_mut().zip(p) {
|
||||
*acc = acc.saturating_add(u32::from(*v));
|
||||
}
|
||||
s[4] = s[4].saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sum = |k: usize| if whole { dense.get(k) } else { sparse.get(&k) };
|
||||
// Not anti-aliased: the raster as drawn, to read each pixel's center from.
|
||||
let drawn = (!self.anti_alias && wide > 1.0).then(|| px.to_vec());
|
||||
let to_raster = space.to_doc.inverse();
|
||||
let model = RasterEffectsSettings { color_model: self.color_model, ..Default::default() };
|
||||
// In a view that isn't turned, the raster pixels along the image's edges take the share of
|
||||
// them it covers; otherwise those whose center lies in it are in it.
|
||||
let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0);
|
||||
let image = to_raster.transform_rect_bbox(area);
|
||||
let cover =
|
||||
|x: f64, y: f64| (image.x1.min(x + 1.0) - image.x0.max(x)).clamp(0.0, 1.0) * (image.y1.min(y + 1.0) - image.y0.max(y)).clamp(0.0, 1.0);
|
||||
for (i, p) in px.iter_mut().enumerate() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let (at, share) = if straight {
|
||||
// The image pixel nearest the raster pixel's center.
|
||||
let q = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let at = (((q.x - area.x0) / cell).floor().clamp(0.0, cols - 1.0), ((q.y - area.y0) / cell).floor().clamp(0.0, rows - 1.0));
|
||||
(Some(at), cover(x as f64, y as f64))
|
||||
} else {
|
||||
(pixel_at(x, y), 1.0)
|
||||
};
|
||||
let Some(at) = at.filter(|_| share > 0.0) else {
|
||||
*p = [0; 4];
|
||||
continue;
|
||||
};
|
||||
// An image pixel's color, over the share of the raster pixel the image covers. (A raster
|
||||
// pixel left as drawn holds art the image covers whole.)
|
||||
let made = if let Some(s) = sum(slot(at)).filter(|s| s[4] > 0) {
|
||||
Some([0, 1, 2, 3].map(|k| ((u64::from(s[k]) + u64::from(s[4] / 2)) / u64::from(s[4])).min(255) as u8))
|
||||
} else {
|
||||
drawn.as_ref().map(|drawn| {
|
||||
let center = to_raster * Point::new(area.x0 + (at.0 + 0.5) * cell, area.y0 + (at.1 + 0.5) * cell);
|
||||
let (x, y) = (center.x.floor(), center.y.floor());
|
||||
let inside = x >= 0.0 && y >= 0.0 && x < w as f64 && y < h as f64;
|
||||
if inside { drawn.get(y as usize * w + x as usize).copied().unwrap_or_default() } else { [0; 4] }
|
||||
})
|
||||
};
|
||||
if let Some(v) = made {
|
||||
*p = if share < 1.0 { v.map(|c| (f64::from(c) * share).round() as u8) } else { v };
|
||||
}
|
||||
// White goes under the art first, over the image's share of the pixel, then the color
|
||||
// model applies, as in Object › Rasterize.
|
||||
if self.white {
|
||||
let under = ((255.0 * share).round() as u8).saturating_sub(p[3]);
|
||||
*p = p.map(|c| c.saturating_add(under));
|
||||
}
|
||||
model.finish_pixels(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +411,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
},
|
||||
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
RASTERIZE => RasterFx::Rasterize(Rasterize::parse(&p)),
|
||||
id => RasterFx::Pixel(pixel::parse(id, &p)?),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,10 @@ fn scale_effect_scales_distances_only() {
|
||||
scale_effect(&mut e, 2.0);
|
||||
assert_eq!((&e.params["x"], &e.params["y"], &e.params["blur"]), (&json!(6.0), &json!(8.0), &json!(10.0)));
|
||||
assert_eq!(e.params["opacity"], json!(75.0));
|
||||
// Rasterize's Add Around Object is a distance; its resolution isn't.
|
||||
let mut r = new_effect(RASTERIZE, &json!({"addAround": 10, "resolution": 150})).unwrap();
|
||||
scale_effect(&mut r, 2.0);
|
||||
assert_eq!((&r.params["addAround"], &r.params["resolution"]), (&json!(20.0), &json!(150)));
|
||||
// Outline Stroke without a weight follows its stroke's: nothing to scale.
|
||||
let mut o = Effect { id: "path.outlineStroke".into(), params: json!(null), visible: true };
|
||||
scale_effect(&mut o, 2.0);
|
||||
|
||||
@@ -46,6 +46,8 @@ vectorcraft-affinity = { workspace = true, features = ["synth"] }
|
||||
crc32fast = { workspace = true }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# A variable font built in code (no font file is committed).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
wat = { workspace = true }
|
||||
# Zstandard streams for the Illustrator editing data fuzz tests.
|
||||
ruzstd = { workspace = true }
|
||||
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -859,9 +859,16 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
|
||||
Ok(library)
|
||||
}
|
||||
|
||||
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
|
||||
/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is
|
||||
/// refused with advice instead (#525).
|
||||
/// The most anchor points an Image Trace may make, a guard for memory: a trace past it is refused
|
||||
/// with advice instead (#525). A trace keeps about 90 bytes an anchor in the document (measured on
|
||||
/// code-drawn photos of 2000 × 2000 to 6000 × 4000 pixels), so 8,000,000 anchors are about
|
||||
/// 0.7 GB, with a peak of about 1 GB while tracing: a 4000 × 3000 photo at High Fidelity Photo
|
||||
/// makes about 1.9 million, and even at Paths 100, Noise 1 and every tone about 7.9 million. On the
|
||||
/// web, where a page has at most 4 GB of memory, the limit stays at 2,000,000.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const MAX_TRACE_ANCHORS: usize = 8_000_000;
|
||||
/// The most anchor points an Image Trace may make on the web (see the native limit).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`.
|
||||
@@ -975,6 +982,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let library = trace_library(s, &mut params)?;
|
||||
let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height));
|
||||
// Pixel space of the decoded raster → the image object's pixel space → document.
|
||||
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
|
||||
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
|
||||
@@ -1044,7 +1052,34 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors }))
|
||||
Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors, "warnings": warnings }))
|
||||
}
|
||||
|
||||
/// Images larger than this on a side are traced, but more slowly: the trace says so.
|
||||
pub const LARGE_TRACE_SIDE: u32 = 1500;
|
||||
|
||||
/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that a
|
||||
/// large image takes longer (the public documentation's warning for images over 1500 px), and that
|
||||
/// one over the tracer's [`tr::MAX_PIXELS`] was traced scaled down.
|
||||
pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32)) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
if size.0.max(size.1) > LARGE_TRACE_SIDE {
|
||||
out.push(format!(
|
||||
"The image is {} × {} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster.",
|
||||
size.0, size.1
|
||||
));
|
||||
}
|
||||
if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) {
|
||||
out.push(format!(
|
||||
"The image was traced at {} × {} pixels, scaled down from {} × {} ({} megapixels at most).",
|
||||
traced.0,
|
||||
traced.1,
|
||||
size.0,
|
||||
size.1,
|
||||
tr::MAX_PIXELS >> 20
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
@@ -1113,3 +1148,21 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&targets) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trace_limit_tests {
|
||||
use super::{MAX_TRACE_ANCHORS, trace_warnings};
|
||||
|
||||
#[test]
|
||||
fn trace_warnings_name_a_large_or_scaled_down_image() {
|
||||
assert!(trace_warnings((1500, 1500), (1500, 1500)).is_empty());
|
||||
assert_eq!(trace_warnings((1501, 10), (1501, 10)).len(), 1);
|
||||
let w = trace_warnings((16000, 12000), (9459, 7094));
|
||||
assert_eq!(w.len(), 2, "{w:?}");
|
||||
assert!(w[0].contains("16000 × 12000") && w[1].contains("9459 × 7094") && w[1].contains("64 megapixels"), "{w:?}");
|
||||
}
|
||||
|
||||
// A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit;
|
||||
// the limit still guards memory (about 90 bytes an anchor). Checked when the tests build.
|
||||
const _: () = assert!(MAX_TRACE_ANCHORS >= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000);
|
||||
}
|
||||
@@ -201,6 +201,16 @@ fn subpath_len(sp: &SubPath) -> f64 {
|
||||
PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum()
|
||||
}
|
||||
|
||||
/// The bound, in points, on the length of a `path.reshapeSegment` delta and on the x and y of a
|
||||
/// point given to `path.convertAnchor` or `path.freehand`. The blend spine commands set the same
|
||||
/// bound (`object.blend.spine.reshapeSegment`, `object.blend.spine.moveAnchor`).
|
||||
const MAX_INPUT: f64 = 1e9;
|
||||
|
||||
/// Whether `q` is finite and its x and y are each smaller than [`MAX_INPUT`] in size.
|
||||
fn within_input_bound(q: Point) -> bool {
|
||||
q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT
|
||||
}
|
||||
|
||||
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
@@ -541,6 +551,9 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.freehand";
|
||||
let samples = samples_param(p, "points", C)?;
|
||||
if samples.iter().any(|(q, _)| !within_input_bound(*q)) {
|
||||
return Err(bad(C, "x and y must be finite"));
|
||||
}
|
||||
let pressure = stroke_pressure(&samples);
|
||||
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
||||
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
|
||||
@@ -857,6 +870,9 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(Some(x), Some(y)) => Some(Point::new(x, y)),
|
||||
_ => None,
|
||||
};
|
||||
if target.is_some_and(|q| !within_input_bound(q)) {
|
||||
return Err(bad(C, "x and y must be finite"));
|
||||
}
|
||||
s.edit("Convert Anchor Point", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -886,7 +902,7 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
if !(dv.x.is_finite() && dv.y.is_finite()) {
|
||||
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
|
||||
return Err(bad(C, "invalid delta"));
|
||||
}
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -426,9 +426,10 @@ fn is_container(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image rendered at
|
||||
/// the document's raster effects resolution, with whether they all paint below it (shadows and
|
||||
/// outer glows: the image then holds just them). A group's or layer's image leaves out its
|
||||
/// members' raster effects, which are expanded with them. `None` when it has none.
|
||||
/// the document's raster effects resolution or a Rasterize effect's finer one, with whether they
|
||||
/// all paint below it (shadows and outer glows: the image then holds just them). A group's or
|
||||
/// layer's image leaves out its members' raster effects, which are expanded with them. `None`
|
||||
/// when it has none.
|
||||
fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> {
|
||||
let fx = rasterfx::raster_fx(n);
|
||||
if fx.is_empty() {
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Font List",
|
||||
[],
|
||||
None,
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
||||
"{family?, style?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names] (upright styles by weight, then italics; an error when the family isn't available), axes: [{tag, name, min, default, max, value, hidden}] (a variable font's variation axes, the Character panel's Variable Font sliders, with `value` the setting of `style`, else of the first style; empty for a static font; `hidden`: the font asks for the axis not to be shown)}",
|
||||
always,
|
||||
font_list
|
||||
),
|
||||
@@ -408,8 +408,9 @@ 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.
|
||||
// Another font: none of the old one's versions or Variable Font settings.
|
||||
r.style.font_version = None;
|
||||
r.style.font_axes.clear();
|
||||
changed = true;
|
||||
n += 1;
|
||||
}
|
||||
@@ -453,7 +454,23 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// The name as the font spells it.
|
||||
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
|
||||
Ok(json!({ "family": name, "styles": db.styles(family) }))
|
||||
let styles = db.styles(family);
|
||||
let style = str_param(p, "style").map(str::to_string).or_else(|| styles.first().cloned()).unwrap_or_else(|| "Regular".into());
|
||||
let axes: Vec<Value> = db
|
||||
.face(family, &style)
|
||||
.map(|face| {
|
||||
let axes = face.axes();
|
||||
axes.iter()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"tag": String::from_utf8_lossy(&a.tag), "name": a.name, "min": a.min, "default": a.default, "max": a.max,
|
||||
"value": face.axis_value(a.tag).unwrap_or(a.default), "hidden": a.hidden,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(json!({ "family": name, "styles": styles, "axes": axes }))
|
||||
}
|
||||
None => Ok(json!({ "families": *db.menu_family_list() })),
|
||||
}
|
||||
|
||||
@@ -446,21 +446,51 @@ fn render_png(
|
||||
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height, warning))
|
||||
}
|
||||
|
||||
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.rasterize";
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let ppi = f64_or(p, "ppi", st.doc.raster_effects_ppi);
|
||||
/// The image Object › Rasterize makes of some art, before it goes in a document
|
||||
/// ([`rasterize_art`]).
|
||||
pub(crate) struct Rasterized {
|
||||
png: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Image pixels → document.
|
||||
xf: Affine,
|
||||
/// The art's outline, with Create Clipping Mask.
|
||||
clip: Option<(PathData, vectorcraft_geom::FillRule)>,
|
||||
/// The note that an image in the art couldn't be decoded ([`render_png`]).
|
||||
warning: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Rasterized {
|
||||
/// The image as a new object of `d` (its pixels in `d.images`), in a clip group with the art's
|
||||
/// outline when it has one.
|
||||
pub(crate) fn into_node(self, d: &mut vectorcraft_doc::Document) -> Node {
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png));
|
||||
let id = d.alloc_id();
|
||||
let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default() };
|
||||
let node = Node::new(id, NodeKind::Image(Box::new(image)));
|
||||
match self.clip {
|
||||
Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node),
|
||||
None => node,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command
|
||||
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
|
||||
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
|
||||
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
|
||||
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
|
||||
if !(1.0..=2400.0).contains(&ppi) {
|
||||
return Err(bad(C, "ppi must be between 1 and 2400"));
|
||||
return Err(bad(cmd, "ppi must be between 1 and 2400"));
|
||||
}
|
||||
// The document's raster effects settings, changed by the params.
|
||||
let mut look = st.doc.raster_effects.clone();
|
||||
let mut look = doc.raster_effects.clone();
|
||||
let mut background = (look.background == vectorcraft_doc::Background::White).then_some([255; 3]);
|
||||
let mut anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
|
||||
for (k, v) in p.as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) {
|
||||
match k.as_str() {
|
||||
"background" => background = fileio::background(v).map_err(|e| bad(C, e))?,
|
||||
"background" => background = fileio::background(v).map_err(|e| bad(cmd, e))?,
|
||||
"antiAlias" => {
|
||||
anti_alias = match v {
|
||||
Value::Bool(b) => {
|
||||
@@ -470,50 +500,65 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
AntiAlias::None
|
||||
}
|
||||
}
|
||||
v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(C, e))?,
|
||||
v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(cmd, e))?,
|
||||
}
|
||||
}
|
||||
"padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, C, k)?,
|
||||
"colorModel" => look.color_model = super::rasterfx::color_model(v, st.doc.color_mode, C)?,
|
||||
"clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(C, "clippingMask must be true or false"))?,
|
||||
"padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, cmd, k)?,
|
||||
"colorModel" => look.color_model = super::rasterfx::color_model(v, doc.color_mode, cmd)?,
|
||||
"clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(cmd, "clippingMask must be true or false"))?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let raster = RasterExportOptions { ppi, background, anti_alias, ..Default::default() };
|
||||
let pad = look.add_around;
|
||||
let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad);
|
||||
let scale = ppi / 72.0;
|
||||
// Snap to whole pixels.
|
||||
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
|
||||
let nodes: Vec<Node> = roots
|
||||
.iter()
|
||||
.filter_map(|id| st.doc.node(*id).cloned())
|
||||
let nodes: Vec<Node> = nodes
|
||||
.into_iter()
|
||||
.map(|mut n| {
|
||||
n.visible = true;
|
||||
n
|
||||
})
|
||||
.collect();
|
||||
// Create Clipping Mask: the image is clipped to the art's outline.
|
||||
// What the art draws, its geometry effects applied.
|
||||
let b = nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, vectorcraft_render::drawn_bounds(doc, n)));
|
||||
let b = b.ok_or_else(|| bad(cmd, "selection has no bounds"))?.inflate(pad, pad);
|
||||
let scale = ppi / 72.0;
|
||||
// Snap to whole pixels.
|
||||
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
|
||||
// Create Clipping Mask: the image is clipped to the art's outline with its geometry effects
|
||||
// applied, as on the canvas.
|
||||
let clip = look
|
||||
.clipping_mask
|
||||
.then(|| vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())))
|
||||
.then(|| {
|
||||
let outlines = nodes.iter().filter_map(|n| {
|
||||
let (bp, rule) = vectorcraft_render::drawn_outline(doc, n)?;
|
||||
let mut p = Node::path(n.id, PathData::from_bezpath(&bp), Appearance::default());
|
||||
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
|
||||
*r = rule;
|
||||
}
|
||||
Some(Arc::new(p))
|
||||
});
|
||||
vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), outlines.collect()))
|
||||
})
|
||||
.flatten()
|
||||
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
|
||||
let tmp = isolated_doc(&st.doc, nodes);
|
||||
let (png, w, h, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let tmp = isolated_doc(doc, nodes);
|
||||
let (png, width, height, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
Ok(Rasterized { png, width, height, xf, clip, warning })
|
||||
}
|
||||
|
||||
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.rasterize";
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect();
|
||||
let image = rasterize_art(&st.doc, nodes, p, C)?;
|
||||
let (w, h, warning) = (image.width, image.height, image.warning);
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "nothing selected"));
|
||||
}
|
||||
let id = s.edit("Rasterize", |d, sel| {
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let mut node =
|
||||
Node::new(id, NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })));
|
||||
if let Some((path, rule)) = clip {
|
||||
node = super::rasterfx::clip_group(d, path, rule, node);
|
||||
}
|
||||
let node = image.into_node(d);
|
||||
let id = d.wrap(&roots, |_| node)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -276,6 +276,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"charAlign",
|
||||
"proportionalMetrics",
|
||||
"digits",
|
||||
"fontAxes",
|
||||
"burasagari",
|
||||
"composer",
|
||||
];
|
||||
@@ -293,6 +294,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
|
||||
let char_align = super::textedit::char_align_param(p, C)?;
|
||||
let digits = super::textedit::digits_param(p, C)?;
|
||||
let font_axes = super::textedit::font_axes_param(p, C)?;
|
||||
let mojikumi = match p.get("mojikumi") {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
@@ -378,6 +380,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("proportionalMetrics") {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
if let Some(v) = &font_axes {
|
||||
super::textedit::change_font_axes(st, v);
|
||||
}
|
||||
};
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
//! model, background, anti-aliasing, room around the art). Shadows and outer glows only add the
|
||||
//! effect: the image (with the object's own area knocked out) goes under the untouched vector
|
||||
//! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it
|
||||
//! with the image. Expand Appearance makes its images with the same helpers.
|
||||
//! with the image. An object whose only raster effect is a Rasterize of its own is replaced with
|
||||
//! the image Object › Rasterize makes with that effect's options. Expand Appearance makes its
|
||||
//! images with the same helpers.
|
||||
//!
|
||||
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
|
||||
//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those
|
||||
//! into images the same way ([`flatten_pixel_effects`]).
|
||||
//! effects (Radial Blur, Unsharp Mask…) or Rasterize: SVG export turns only the objects carrying
|
||||
//! one of those into images the same way ([`flatten_pixel_effects`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -289,11 +291,15 @@ fn below_images(d: &mut Document, n: &Node) -> Option<Vec<Node>> {
|
||||
}
|
||||
|
||||
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d`
|
||||
/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays
|
||||
/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds
|
||||
/// just them; with `members` too, a group's or layer's image leaves out its members' raster
|
||||
/// effects (they get images of their own).
|
||||
/// rendered at the document's raster effects resolution, or at a Rasterize effect's finer one (an
|
||||
/// object whose only raster effect is its own Rasterize gets [`rasterize_effect_image`]'s image),
|
||||
/// without `n`'s transparency (that stays on the object). With `below` (they all paint below it:
|
||||
/// shadows and outer glows) the image holds just them; with `members` too, a group's or layer's
|
||||
/// image leaves out its members' raster effects (they get images of their own).
|
||||
pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option<Node> {
|
||||
if !below && let Some(image) = rasterize_effect_image(d, n) {
|
||||
return Some(image);
|
||||
}
|
||||
let mut whole = n.clone();
|
||||
(whole.opacity, whole.blend) = (1.0, Default::default());
|
||||
whole.extras_mut().mask = None;
|
||||
@@ -307,10 +313,37 @@ pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: boo
|
||||
strip_raster(&mut b, false);
|
||||
b
|
||||
});
|
||||
let scale = effects_scale(d);
|
||||
// A Rasterize effect at a finer resolution than the document's is rendered at its own.
|
||||
let scale = raster_fx(n)
|
||||
.iter()
|
||||
.filter_map(|fx| if let RasterFx::Rasterize(r) = fx { r.ppi } else { None })
|
||||
.fold(effects_scale(d), |s, ppi| s.max(ppi / 72.0));
|
||||
effect_image(d, &whole, bare.as_ref(), scale)
|
||||
}
|
||||
|
||||
/// The image Object › Rasterize makes of `n` with the options of `n`'s Effect › Rasterize, when
|
||||
/// that is its only visible raster effect and is its own (not a fill's or a stroke's): made of `n`
|
||||
/// without that effect and without its transparency, which stays on the object. `None` otherwise,
|
||||
/// or when the image can't be made.
|
||||
fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let [RasterFx::Rasterize(r)] = raster_fx(n)[..] else { return None };
|
||||
let at = n.appearance.effects.iter().position(|e| e.visible && e.id == effects::RASTERIZE)?;
|
||||
let p = json!({
|
||||
"ppi": r.ppi.unwrap_or(d.raster_effects_ppi),
|
||||
"colorModel": r.color_model.id(),
|
||||
"background": if r.white { "white" } else { "transparent" },
|
||||
"antiAlias": if !r.anti_alias { "none" } else if r.hinted { "type" } else { "art" },
|
||||
"clippingMask": r.clipping_mask,
|
||||
"addAround": r.add_around,
|
||||
});
|
||||
let mut whole = n.clone();
|
||||
whole.appearance.effects.remove(at);
|
||||
(whole.opacity, whole.blend) = (1.0, Default::default());
|
||||
whole.extras_mut().mask = None;
|
||||
let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize").ok()?;
|
||||
Some(image.into_node(d))
|
||||
}
|
||||
|
||||
/// `v` (an object without its raster effects) replaced by `image` of the object with them: the
|
||||
/// image takes its place with its id, name and transparency (a layer keeps the image as its only
|
||||
/// member).
|
||||
@@ -409,7 +442,8 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
|
||||
enum Which {
|
||||
/// Every raster effect (PDF).
|
||||
All,
|
||||
/// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects).
|
||||
/// Objects with a Photoshop-style effect or Rasterize, which SVG can't draw (with all their
|
||||
/// raster effects).
|
||||
Pixel,
|
||||
}
|
||||
|
||||
@@ -418,7 +452,7 @@ fn imaged(n: &Node, which: Which) -> bool {
|
||||
let fx = raster_fx(n);
|
||||
match which {
|
||||
Which::All => !fx.is_empty(),
|
||||
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))),
|
||||
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,8 +553,8 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
||||
}
|
||||
|
||||
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
|
||||
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
|
||||
/// other raster effects live), or `None` when there are none.
|
||||
/// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its
|
||||
/// filters draw the other raster effects live), or `None` when there are none.
|
||||
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -255,6 +255,7 @@ pub(crate) struct CharChange {
|
||||
char_align: Option<vectorcraft_doc::CharAlign>,
|
||||
proportional_metrics: Option<bool>,
|
||||
digits: Option<vectorcraft_doc::Digits>,
|
||||
font_axes: Option<AxisChange>,
|
||||
}
|
||||
|
||||
/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in.
|
||||
@@ -270,6 +271,60 @@ pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_do
|
||||
}))
|
||||
}
|
||||
|
||||
/// Variable Font settings to change: by axis tag, a value in the axis's units, or `None` to go
|
||||
/// back to the style's own setting of that axis.
|
||||
pub(crate) type AxisChange = std::collections::BTreeMap<String, Option<f64>>;
|
||||
|
||||
/// `fontAxes: {"wght": 650, "wdth": null, …}` → the Variable Font settings to change. A tag is four
|
||||
/// printable ASCII characters (`wght`); a value is a number, or null.
|
||||
pub(crate) fn font_axes_param(p: &Value, cmd: &str) -> Result<Option<AxisChange>> {
|
||||
let Some(v) = p.get("fontAxes").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let obj = v.as_object().ok_or_else(|| bad(cmd, "`fontAxes` must be an object of axis tags and values, such as {\"wght\": 650}"))?;
|
||||
let mut out = AxisChange::new();
|
||||
for (tag, value) in obj {
|
||||
if tag.len() != 4 || !tag.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
|
||||
return Err(bad(cmd, format!("`{tag}` is not an axis tag: a tag is four characters, such as \"wght\"")));
|
||||
}
|
||||
let value = match value {
|
||||
Value::Null => None,
|
||||
v => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("the value of axis `{tag}` must be a number or null")))?),
|
||||
};
|
||||
out.insert(tag.clone(), value);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Change the Variable Font settings of `st` by `change`. With its font available, a value is
|
||||
/// clamped to its axis's range, a tag its font has no axis for is left out, and a value equal to
|
||||
/// the style's own setting is removed; with the font missing, the values are kept as given.
|
||||
pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let face = match db.resolve(&st.font_family, &st.font_style) {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
|
||||
Some(db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).unwrap_or(f))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let axes = face.as_ref().map(|f| f.axes()).unwrap_or_default();
|
||||
for (tag, value) in change {
|
||||
let Some(v) = value else {
|
||||
st.font_axes.remove(tag);
|
||||
continue;
|
||||
};
|
||||
let Some(face) = &face else {
|
||||
st.font_axes.insert(tag.clone(), *v);
|
||||
continue;
|
||||
};
|
||||
let Some(axis) = axes.iter().find(|a| a.tag.as_slice() == tag.as_bytes()) else { continue };
|
||||
let v = v.clamp(f64::from(axis.min.min(axis.max)), f64::from(axis.max.max(axis.min)));
|
||||
if face.axis_value(axis.tag).is_some_and(|own| f64::from(own) == v) {
|
||||
st.font_axes.remove(tag);
|
||||
} else {
|
||||
st.font_axes.insert(tag.clone(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
|
||||
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
|
||||
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
@@ -358,6 +413,7 @@ impl CharChange {
|
||||
char_align: char_align_param(p, cmd)?,
|
||||
proportional_metrics: flag("proportionalMetrics"),
|
||||
digits: digits_param(p, cmd)?,
|
||||
font_axes: font_axes_param(p, cmd)?,
|
||||
};
|
||||
if c.size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(cmd, "size must be positive"));
|
||||
@@ -388,6 +444,7 @@ impl CharChange {
|
||||
&& self.char_align.is_none()
|
||||
&& self.proportional_metrics.is_none()
|
||||
&& self.digits.is_none()
|
||||
&& self.font_axes.is_none()
|
||||
}
|
||||
|
||||
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
|
||||
@@ -416,9 +473,13 @@ impl CharChange {
|
||||
if let Some(v) = &self.style {
|
||||
st.font_style = v.clone();
|
||||
}
|
||||
// Another font: none of the old one's versions.
|
||||
// Another font: none of the old one's versions or Variable Font settings.
|
||||
if self.font.is_some() || self.style.is_some() {
|
||||
st.font_version = None;
|
||||
st.font_axes.clear();
|
||||
}
|
||||
if let Some(v) = &self.font_axes {
|
||||
change_font_axes(st, v);
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
st.size = v;
|
||||
@@ -484,7 +545,8 @@ impl CharChange {
|
||||
}
|
||||
|
||||
/// Type › Enable Missing Glyph Protection: after a font change, the characters of `runs` their new
|
||||
/// font has no glyph for, but their font `before` the change had, keep that font.
|
||||
/// font has no glyph for, but their font `before` the change had, keep that font: its family,
|
||||
/// style, version and Variable Font settings.
|
||||
pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>) {
|
||||
fn chars(runs: &[TextRun]) -> impl Iterator<Item = (usize, char, &CharStyle)> {
|
||||
runs.iter()
|
||||
@@ -503,7 +565,8 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
|
||||
.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).
|
||||
// Byte ranges that keep a font (family, style, version and Variable Font settings).
|
||||
let font = |st: &CharStyle| (st.font_family.clone(), st.font_style.clone(), st.font_version.clone(), st.font_axes.clone());
|
||||
let mut keep: Vec<(usize, usize, &CharStyle)> = vec![];
|
||||
for ((at, c, old), (_, _, new)) in chars(before).zip(chars(runs)) {
|
||||
let changed = (&old.font_family, &old.font_style) != (&new.font_family, &new.font_style);
|
||||
@@ -512,7 +575,7 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
|
||||
}
|
||||
let end = at + c.len_utf8();
|
||||
match keep.last_mut() {
|
||||
Some((_, e, st)) if *e == at && (&st.font_family, &st.font_style) == (&old.font_family, &old.font_style) => *e = end,
|
||||
Some((_, e, st)) if *e == at && font(st) == font(old) => *e = end,
|
||||
_ => keep.push((at, end, old)),
|
||||
}
|
||||
}
|
||||
@@ -520,6 +583,8 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
|
||||
edit::style_range(runs, a, b, |st| {
|
||||
st.font_family.clone_from(&old.font_family);
|
||||
st.font_style.clone_from(&old.font_style);
|
||||
st.font_version.clone_from(&old.font_version);
|
||||
st.font_axes.clone_from(&old.font_axes);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
for (k, x) in [
|
||||
("font_version", json!(v.font_version)),
|
||||
("fontAxes", json!(v.font_axes)),
|
||||
("leading", json!(v.leading)),
|
||||
("kerning", json!(v.kerning)),
|
||||
("features", json!(v.features)),
|
||||
@@ -250,6 +251,21 @@ fn restyle<T: Serialize + DeserializeOwned + Clone>(
|
||||
serde_json::from_value(Value::Object(m)).unwrap_or_else(|_| cur.clone())
|
||||
}
|
||||
|
||||
/// `st`, written over `before` by a character style's `attrs`: when its font or style changed,
|
||||
/// it keeps none of the old one's versions or Variable Font settings unless the style sets them.
|
||||
fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map<String, Value>) -> CharStyle {
|
||||
if st.font_family == before.font_family && st.font_style == before.font_style {
|
||||
return st;
|
||||
}
|
||||
if st.font_version.as_ref().is_some_and(|v| attrs.get("font_version") != Some(&json!(v))) {
|
||||
st.font_version = None;
|
||||
}
|
||||
if !st.font_axes.is_empty() && attrs.get("fontAxes") != Some(&json!(st.font_axes)) {
|
||||
st.font_axes.clear();
|
||||
}
|
||||
st
|
||||
}
|
||||
|
||||
/// Every text object id in the document.
|
||||
fn all_text(d: &Document) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
@@ -293,7 +309,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
Kind::Char => {
|
||||
for r in &mut t.runs {
|
||||
if r.style.style_name == target {
|
||||
r.style = restyle(&r.style, char_attrs, old, new);
|
||||
r.style = font_change(&r.style, restyle(&r.style, char_attrs, old, new), new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -447,7 +463,7 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
// Characters: Normal then the style over the defaults (clear) or over the current attributes.
|
||||
let char_fn = |cur: &CharStyle| -> Result<CharStyle> {
|
||||
let base = if clear { with_attrs(&CharStyle::default(), &normal, &c)? } else { cur.clone() };
|
||||
let mut st = with_attrs(&base, &attrs, &c)?;
|
||||
let mut st = font_change(&base, with_attrs(&base, &attrs, &c)?, &attrs);
|
||||
st.style_name = style_name.clone();
|
||||
Ok(st)
|
||||
};
|
||||
@@ -827,6 +843,72 @@ mod tests {
|
||||
assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"}));
|
||||
}
|
||||
|
||||
/// A style captured from type without Variable Font settings sets none explicitly (`{}`):
|
||||
/// applying it clears type's settings, and redefining it from type whose settings were reset
|
||||
/// resets the type that uses it.
|
||||
#[test]
|
||||
fn variable_font_settings_are_captured_explicitly() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
|
||||
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let (mut s, source) = session_with_text("Source");
|
||||
let linked = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Linked"})).unwrap()["id"].as_u64().unwrap();
|
||||
let ids = [source, linked];
|
||||
s.execute("text.setStyle", &json!({"ids": ids, "font": TWO_AXIS_FAMILY})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("fontAxes"), Some(&json!({})));
|
||||
s.execute("text.setFormat", &json!({"ids": [linked], "fontAxes": {"wght": 650}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
|
||||
let wght = |s: &Session| ids.map(|id| runs(s, id)[0].style.font_axes.get("wght").copied());
|
||||
assert_eq!(wght(&s), [None, None], "applying the style clears the settings");
|
||||
for (set, want) in [(json!(650), Some(650.0)), (json!(null), None)] {
|
||||
s.execute("text.setFormat", &json!({"ids": [source], "fontAxes": {"wght": set}})).unwrap();
|
||||
s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
|
||||
assert_eq!(wght(&s), [want, want], "redefined from {set}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A style that changes the font, applied or redefined, leaves none of the old font's
|
||||
/// version and Variable Font settings, unless the style sets them.
|
||||
#[test]
|
||||
fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
|
||||
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let (mut s, id) = session_with_text("Source");
|
||||
let set_variable = |s: &mut Session| {
|
||||
s.execute("text.setStyle", &json!({"ids": [id], "font": TWO_AXIS_FAMILY})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "fontAxes": {"wght": 650}})).unwrap();
|
||||
s.edit("version", |d, _| {
|
||||
if let Some(t) = text_mut(d, NodeId(id)) {
|
||||
t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
let font = |s: &Session| {
|
||||
let st = runs(s, id)[0].style.clone();
|
||||
(st.font_family, st.font_version, st.font_axes.get("wght").copied())
|
||||
};
|
||||
set_variable(&mut s);
|
||||
s.execute("charStyle.new", &json!({"name": "Plain", "attrs": {"font_family": "Inter"}})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Same", "attrs": {"size": 20}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Same", "id": id})).unwrap();
|
||||
assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), Some("Version 2.000".to_string()), Some(650.0)), "the same font keeps them");
|
||||
s.execute("charStyle.apply", &json!({"name": "Plain", "id": id})).unwrap();
|
||||
assert_eq!(font(&s), ("Inter".to_string(), None, None));
|
||||
// A style that sets the settings keeps its own.
|
||||
s.execute("charStyle.new", &json!({"name": "Heavy", "attrs": {"font_family": TWO_AXIS_FAMILY, "fontAxes": {"wght": 600}}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Heavy", "id": id})).unwrap();
|
||||
assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), None, Some(600.0)));
|
||||
// Redefined to another font: its users' settings and versions go too.
|
||||
s.execute("charStyle.new", &json!({"name": "Sans", "attrs": {"font_family": TWO_AXIS_FAMILY}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Sans", "id": id})).unwrap();
|
||||
set_variable(&mut s);
|
||||
assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Sans"));
|
||||
s.execute("charStyle.setAttrs", &json!({"name": "Sans", "attrs": {"font_family": "Inter"}})).unwrap();
|
||||
assert_eq!(font(&s), ("Inter".to_string(), None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_leading_new_distinguishes_unset_attributes() {
|
||||
let (mut s, id) = session_with_text("Title");
|
||||
|
||||
@@ -21,6 +21,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_multi,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"text.thread.add",
|
||||
"Thread Text",
|
||||
[],
|
||||
None,
|
||||
"{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type `from` (a click on its out port) into one more frame, put after it in its thread: closed path or area type `to`, a new rectangle `rect`, or a new frame of `from`'s shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
cmd!(
|
||||
"text.thread.releaseSelection",
|
||||
"Release Selection",
|
||||
@@ -205,6 +214,90 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// `text.thread.add`: the out port's loaded text, placed.
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.thread.add";
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type"))?;
|
||||
let style = src.first_style();
|
||||
// The frame to thread into: an existing object (`to`), or a new one beside `from`.
|
||||
let (to, new) = match (given("to"), given("rect"), given("at")) {
|
||||
(Some(v), None, None) => {
|
||||
let to = checked_id(s, v, C)?;
|
||||
let parent = d.parent_of(to).and_then(|i| d.node(i));
|
||||
let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone()).is_some());
|
||||
if to == from || !frame || parent.is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {
|
||||
return Err(bad(C, "`to` must be a closed path or area type other than `from`"));
|
||||
}
|
||||
if d.text_threads.iter().any(|t| t.contains(&to)) {
|
||||
return Err(bad(C, "`to` is already threaded"));
|
||||
}
|
||||
(to, None)
|
||||
}
|
||||
(None, Some(v), None) => {
|
||||
let [x, y, w, h] = finite_numbers(v)
|
||||
.filter(|[_, _, w, h]| *w > 0.0 && *h > 0.0)
|
||||
.ok_or_else(|| bad(C, "`rect` must be [x, y, width, height] with a positive size"))?;
|
||||
let frame = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, w, h));
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), frame, vectorcraft_geom::Affine::translate((x, y)))))
|
||||
}
|
||||
(None, None, Some(v)) => {
|
||||
let [x, y] = finite_numbers(v).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
|
||||
let TextKind::Area { frame } = &src.kind else { return Err(bad(C, "`from` must be area type")) };
|
||||
let b = frame.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?;
|
||||
let xf = vectorcraft_geom::Affine::translate((x - b.x0, y - b.y0)) * src.xf;
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), frame.clone(), xf)))
|
||||
}
|
||||
_ => return Err(bad(C, "give one of `to`, `rect` or `at`")),
|
||||
};
|
||||
let appearance = d.node(from).map(|n| n.appearance.clone());
|
||||
let id = s.edit("Thread Text", |d, sel| {
|
||||
let to = match new {
|
||||
Some(t) => {
|
||||
let (parent, idx, _) = d.position(from).ok_or(EngineError::NoNode(from))?;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
|
||||
if let Some(a) = appearance {
|
||||
n.appearance = a;
|
||||
}
|
||||
d.insert(parent, idx + 1, n)?;
|
||||
id
|
||||
}
|
||||
None => {
|
||||
if text_of(d, to).is_none() {
|
||||
let n = d.node(to).cloned().ok_or(EngineError::NoNode(to))?;
|
||||
*d.node_mut(to).ok_or(EngineError::NoNode(to))? = frame_from_path(&n, style).ok_or_else(|| bad(C, "not a closed path"))?;
|
||||
}
|
||||
to
|
||||
}
|
||||
};
|
||||
match d.text_threads.iter_mut().find(|t| t.contains(&from)) {
|
||||
Some(t) => {
|
||||
let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| i + 1);
|
||||
t.insert(at, to);
|
||||
}
|
||||
None => d.text_threads.push(vec![from, to]),
|
||||
}
|
||||
// `to` is new or changed, so [`reflow`] flows the story into it after this edit.
|
||||
sel.set([from, to]);
|
||||
Ok(to)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// An empty area-type frame `frame` placed by `xf`, with the type options of `src`.
|
||||
fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, frame: vectorcraft_geom::PathData, xf: vectorcraft_geom::Affine) -> TextObject {
|
||||
let mut t = src.clone();
|
||||
t.runs = vec![TextRun { text: String::new(), style, inline: None }];
|
||||
t.set_all_paras(src.para_at(0).clone());
|
||||
t.kind = TextKind::Area { frame };
|
||||
t.xf = xf;
|
||||
refresh_bounds(&mut t);
|
||||
t
|
||||
}
|
||||
|
||||
/// Give a node a fresh allocation so [`reflow`] sees its thread as changed.
|
||||
fn touch(d: &mut Document, id: NodeId) {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
@@ -413,6 +506,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn new_id(v: Value) -> u64 {
|
||||
v["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn threads(s: &Session) -> Vec<Vec<u64>> {
|
||||
s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect()
|
||||
}
|
||||
|
||||
/// Bounds of area type `id`'s frame on the page.
|
||||
fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
|
||||
t.xf.transform_rect_bbox(frame.bounds().unwrap())
|
||||
}
|
||||
|
||||
/// #2572: the text loaded from an out port threads into a closed path, into a new frame of the
|
||||
/// first one's shape and size at a point, and into a new rectangle, each in one undo step.
|
||||
#[test]
|
||||
fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() {
|
||||
let (mut s, a, b) = setup();
|
||||
assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b);
|
||||
assert_eq!(threads(&s), [[a, b]]);
|
||||
assert!(!text(&s, b).is_empty(), "the overflow flowed into the path");
|
||||
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back");
|
||||
|
||||
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap());
|
||||
assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click");
|
||||
let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap());
|
||||
assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0));
|
||||
assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from");
|
||||
assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4));
|
||||
assert!(!text(&s, d).is_empty());
|
||||
// A frame threaded from the middle of a thread goes in after it.
|
||||
let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap());
|
||||
assert_eq!(threads(&s), [[a, e, c, d]]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none());
|
||||
assert_eq!(threads(&s), [[a, c, d]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_refuses_bad_params_and_leaves_the_document_alone() {
|
||||
let (mut s, a, b) = setup();
|
||||
let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for p in [
|
||||
json!({"to": b}),
|
||||
json!({"from": b, "at": [0, 0]}),
|
||||
json!({"from": 9999, "at": [0, 0]}),
|
||||
json!({"from": -1, "at": [0, 0]}),
|
||||
json!({"from": a}),
|
||||
json!({"from": a, "to": b, "at": [0, 0]}),
|
||||
json!({"from": a, "to": a}),
|
||||
json!({"from": a, "to": line}),
|
||||
json!({"from": a, "to": 9999}),
|
||||
json!({"from": a, "at": [0]}),
|
||||
json!({"from": a, "at": ["1", 2]}),
|
||||
json!({"from": a, "rect": [0, 0, -10, 10]}),
|
||||
json!({"from": a, "rect": [0, 0, 10, 0]}),
|
||||
json!({"from": a, "rect": [0, 0, 10]}),
|
||||
json!({"from": a, "rect": "big"}),
|
||||
] {
|
||||
assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document");
|
||||
}
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap();
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap());
|
||||
assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_and_remove() {
|
||||
let (mut s, a, b) = setup();
|
||||
|
||||
@@ -577,9 +577,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.
|
||||
// Another font: none of the old one's versions or Variable Font settings.
|
||||
if font.is_some() || style.is_some() {
|
||||
st.font_version = None;
|
||||
st.font_axes.clear();
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v;
|
||||
@@ -613,10 +614,12 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = &font {
|
||||
st.font_family = f.clone();
|
||||
st.font_version = None;
|
||||
st.font_axes.clear();
|
||||
}
|
||||
if let Some(f) = &style {
|
||||
st.font_style = f.clone();
|
||||
st.font_version = None;
|
||||
st.font_axes.clear();
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
@@ -1248,6 +1251,80 @@ mod area_tests {
|
||||
assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]);
|
||||
}
|
||||
|
||||
/// Variable Font settings (Character panel › Variable Font): a character attribute, saved only
|
||||
/// when set, each value clamped to its axis's range, and cleared by another font or style.
|
||||
#[test]
|
||||
fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font};
|
||||
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "lIn lIn", "font": FAMILY, "size": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let saved = |s: &Session| serde_json::to_value(&runs(s)[0].style).unwrap().get("fontAxes").cloned();
|
||||
let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap().width();
|
||||
assert_eq!(saved(&s), None);
|
||||
let regular = width(&s);
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wght": 650.0})));
|
||||
assert!(width(&s) > regular * 1.1, "the type is set heavier and wider: {regular} -> {}", width(&s));
|
||||
// Past the axis's range: its maximum; an axis the font hasn't: left out; others kept.
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000, "opsz": 12, "wdth": 80}})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wdth": 80.0, "wght": 700.0})));
|
||||
// Null goes back to the style's own setting, and so does a value equal to it.
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": null}})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wdth": 80.0})));
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 100}})).unwrap();
|
||||
assert_eq!(saved(&s), None);
|
||||
assert!((width(&s) - regular).abs() < 1e-6);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wdth": 80.0})));
|
||||
// Characters selected with the Type tool: only the range takes it.
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "fontAxes": {"wght": 550}})).unwrap();
|
||||
let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.font_axes.clone())).collect();
|
||||
let axes = |v: &[(&str, f64)]| v.iter().map(|(t, v)| (t.to_string(), *v)).collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(set, [("lIn ".to_string(), axes(&[("wdth", 80.0)])), ("lIn".to_string(), axes(&[("wdth", 80.0), ("wght", 550.0)]))]);
|
||||
// A saved file keeps them.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.runs, runs(&s));
|
||||
// Another style or font clears them: their settings were the old face's.
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "style": "Bold"})).unwrap();
|
||||
assert_eq!(runs(&s).last().map(|r| (r.style.font_style.as_str(), r.style.font_axes.is_empty())), Some(("Bold", true)));
|
||||
s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap();
|
||||
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
|
||||
// A static font has no axes to set.
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
|
||||
assert_eq!(saved(&s), None);
|
||||
// A missing font keeps them as given, for when it is installed.
|
||||
s.execute("text.setStyle", &json!({"font": "No Such Variable Sans"})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000}})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wght": 2000.0})));
|
||||
for (bad, says) in
|
||||
[(json!({"weight": 650}), "not an axis tag"), (json!({"wght": "heavy"}), "must be a number"), (json!(650), "must be an object")]
|
||||
{
|
||||
let err = s.execute("text.setFormat", &json!({"fontAxes": bad})).unwrap_err().to_string();
|
||||
assert!(err.contains(says), "{err}");
|
||||
}
|
||||
// The axes and the setting of a style, as the sliders show them.
|
||||
let list = s.execute("text.fontList", &json!({"family": FAMILY, "style": "Bold"})).unwrap();
|
||||
assert_eq!(
|
||||
list["axes"],
|
||||
json!([
|
||||
{"tag": "wght", "name": "Weight", "min": 400.0, "default": 400.0, "max": 700.0, "value": 700.0, "hidden": false},
|
||||
{"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false},
|
||||
])
|
||||
);
|
||||
assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_type_is_composed_with_line_end_half_width_punctuation() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
|
||||
@@ -908,6 +908,34 @@ fn traced_paths(n: &Node) -> Vec<Arc<Node>> {
|
||||
n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_image_traces_with_a_warning() {
|
||||
// The public documentation: images over 1500 px on a side are traced, only more slowly.
|
||||
let mut s = session();
|
||||
let art = vectorcraft_trace::Raster::from_fn(
|
||||
1600,
|
||||
40,
|
||||
|x, y| if (100..1500).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255; 4] },
|
||||
);
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
|
||||
assert_eq!(r["paths"], 1);
|
||||
let w = r["warnings"].as_array().unwrap();
|
||||
assert_eq!(w.len(), 1, "{w:?}");
|
||||
assert!(w[0].as_str().unwrap().contains("1600 × 40") && w[0].as_str().unwrap().contains("1500"), "{w:?}");
|
||||
// Make and Expand says so too.
|
||||
let r = s.execute("imageTrace.makeAndExpand", &json!({"id": r["id"], "params": {"ignoreWhite": true}})).unwrap();
|
||||
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1));
|
||||
// At 1500 px and under, no warning.
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]));
|
||||
let edge = vectorcraft_trace::Raster::from_fn(1500, 8, |x, _| if x < 700 { [0, 0, 0, 255] } else { [255; 4] });
|
||||
add_image(&mut s, &edge, Affine::IDENTITY);
|
||||
assert_eq!(s.execute("imageTrace.make", &json!({})).unwrap()["warnings"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_make_places_paths_over_the_image() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -811,6 +811,44 @@ fn draw2_commands_reject_bad_params() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
|
||||
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
|
||||
// `path.freehand` refuse a point whose x or y is 1e9 pt or more in size, also for a path that is
|
||||
// not selected: the document stays as it was, with no undo step. A delta just under that bound,
|
||||
// at t 0.05 or 0.95, leaves every handle and the path's bounds finite.
|
||||
let mut s = session();
|
||||
let id = curve(&mut s);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let finite = |s: &Session| {
|
||||
paths(s).iter().all(|(_, pd)| {
|
||||
let mut points = pd.subpaths.iter().flat_map(|sp| &sp.anchors).flat_map(|a| [a.p, a.h_in, a.h_out]);
|
||||
points.all(|q| q.x.is_finite() && q.y.is_finite()) && pd.control_bounds().is_none_or(|b| b.width().is_finite() && b.height().is_finite())
|
||||
})
|
||||
};
|
||||
let h = 1e308;
|
||||
for (cmd, p) in [
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "t": 0.95, "dx": h, "dy": 0})),
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "t": 0.05, "dx": 0, "dy": -h})),
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "t": 0.95, "dx": 0, "dy": h, "perpendicular": true})),
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 1e9, "dy": 0})),
|
||||
("path.convertAnchor", json!({"id": id.0, "anchor": 0, "to": "smooth", "x": h, "y": -h})),
|
||||
("path.freehand", json!({"points": [[0, 0], [h, 0], [-h, h], [h, h]], "keepSelected": false})),
|
||||
("path.freehand", json!({"points": [[0, 0], [h, 0], [-h, h]], "keepSelected": false, "extend": {"id": id.0, "end": "end"}})),
|
||||
] {
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||||
assert!(s.doc().unwrap().doc == before && finite(&s), "{cmd} {p} changed the document");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} left an undo step");
|
||||
}
|
||||
for (t, perpendicular) in [(0.05, false), (0.95, false), (0.05, true), (0.95, true)] {
|
||||
let p = json!({"id": id.0, "segment": 0, "t": t, "dx": 0, "dy": 9.9e8, "perpendicular": perpendicular});
|
||||
s.execute("path.reshapeSegment", &p).unwrap();
|
||||
assert!(finite(&s), "{p}: {:?}", path(&s, id));
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve from (100, 100) to (300, 100) whose first anchor has an out handle at (150, 100).
|
||||
fn curve(s: &mut Session) -> NodeId {
|
||||
let anchors = json!([{"x": 100, "y": 100, "out": [150, 100]}, {"x": 300, "y": 100, "in": [250, 100]}]);
|
||||
|
||||
@@ -1196,6 +1196,55 @@ fn missing_glyph_protection_keeps_glyphs_a_new_font_lacks() {
|
||||
assert_eq!(fonts(&s), [to, to, to]);
|
||||
}
|
||||
|
||||
/// A character that keeps its font through Missing Glyph Protection keeps that font's Variable
|
||||
/// Font settings too; the characters that take the new font lose them.
|
||||
#[test]
|
||||
fn missing_glyph_protection_keeps_the_variable_font_settings_of_the_font_kept() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FROM, two_axis_font};
|
||||
let (to, c) = ("JetBrains Mono", '\u{180}');
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
db.add_font(two_axis_font().unwrap());
|
||||
if !db.face(FROM, "Regular").is_some_and(|f| f.covers(c)) || db.face(to, "Regular").is_some_and(|f| f.covers(c)) {
|
||||
return; // An installed font of that name covers the character.
|
||||
}
|
||||
let mut s = new_doc();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": FROM})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
|
||||
let fonts = |s: &Session| [0, 1, 3].map(|b| style_at(s, id, b)).map(|st| (st.font_family, st.font_axes.get("wght").copied()));
|
||||
let (kept, moved) = ((FROM.to_string(), Some(650.0)), (to.to_string(), None));
|
||||
for change in [json!({"font": to}), json!({"id": id.0, "start": 0, "end": 4, "font": to})] {
|
||||
let cmd = if change.get("id").is_some() { "text.setRangeStyle" } else { "text.setStyle" };
|
||||
s.execute(cmd, &change).unwrap();
|
||||
assert_eq!(fonts(&s), [moved.clone(), kept.clone(), moved.clone()], "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A character that keeps its font through Missing Glyph Protection keeps the version of it the
|
||||
/// type names.
|
||||
#[test]
|
||||
fn missing_glyph_protection_keeps_the_version_of_the_font_kept() {
|
||||
use vectorcraft_text::test_fonts::{TWIN_FAMILY as FROM, TWIN_VERSIONS, twin_font};
|
||||
let (to, c) = ("JetBrains Mono", '\u{180}');
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
db.add_font(twin_font(false).unwrap());
|
||||
let older = db.face_version(FROM, "Regular", Some(TWIN_VERSIONS[0]));
|
||||
if !older.is_some_and(|f| f.version == TWIN_VERSIONS[0] && f.covers(c)) || db.face(to, "Regular").is_some_and(|f| f.covers(c)) {
|
||||
return; // An installed font of that name covers the character.
|
||||
}
|
||||
let mut s = new_doc();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": FROM})).unwrap()["id"].as_u64().unwrap());
|
||||
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs[0].style.font_version = Some(TWIN_VERSIONS[0].into());
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"font": to})).unwrap();
|
||||
let versions = [0, 1, 3].map(|b| (style_at(&s, id, b).font_family, style_at(&s, id, b).font_version));
|
||||
let (kept, moved) = ((FROM.to_string(), Some(TWIN_VERSIONS[0].to_string())), (to.to_string(), None));
|
||||
assert_eq!(versions, [moved.clone(), kept, moved]);
|
||||
}
|
||||
|
||||
// ---------- #394, fourth batch ----------
|
||||
|
||||
/// Type › Type Object Selection by Path Only (#394): on, a click among point type's glyphs no
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Effect → Document Raster Effects Settings (`document.rasterEffectsSettings`): the settings, the
|
||||
//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults.
|
||||
//! raster effect images they make (PDF export, Expand Appearance), the Rasterize defaults, and
|
||||
//! Effect › Rasterize's image.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
|
||||
@@ -199,6 +200,156 @@ fn rasterize_takes_the_document_settings_as_defaults() {
|
||||
assert!(s.execute("object.rasterize", &json!({"colorModel": "lab"})).is_err());
|
||||
}
|
||||
|
||||
/// An image Object › Rasterize or Expand Appearance made, `n` or the image in its clip group:
|
||||
/// (width, height, where its pixels lie, its PNG, the clipping path's geometry).
|
||||
fn rasterized(d: &Document, n: &vectorcraft_doc::Node) -> (u32, u32, vectorcraft_geom::Affine, Vec<u8>, Option<vectorcraft_geom::PathData>) {
|
||||
let (image, clip) = match &n.kind {
|
||||
NodeKind::Group { children, clip: true } => (&*children[1], children[0].path_data().cloned()),
|
||||
_ => (n, None),
|
||||
};
|
||||
let NodeKind::Image(im) = &image.kind else { panic!("an image: {:?}", image.kind_label()) };
|
||||
(im.width, im.height, im.xf, d.images[&im.key].bytes.to_vec(), clip)
|
||||
}
|
||||
|
||||
/// A rectangle of 40 × 30 pt with its default stroke and a red fill, selected.
|
||||
fn red_rectangle(s: &mut Session) -> NodeId {
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 50.5, "y": 50.25, "width": 40, "height": 30})).unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rasterize_effect_keeps_the_path_and_expands_to_the_image_object_rasterize_makes() {
|
||||
let cases = [
|
||||
(
|
||||
json!({"useDocumentResolution": false, "resolution": 150, "background": "white", "colorModel": "grayscale", "antiAlias": "none", "addAround": 3}),
|
||||
json!({"ppi": 150, "background": "white", "colorModel": "grayscale", "antiAlias": "none", "addAround": 3}),
|
||||
),
|
||||
// At the document's resolution (300 ppi here), clipped to the art's outline.
|
||||
(
|
||||
json!({"clippingMask": true, "antiAlias": "type"}),
|
||||
json!({"ppi": 300, "clippingMask": true, "antiAlias": "type", "background": "transparent"}),
|
||||
),
|
||||
];
|
||||
for (effect, object) in cases {
|
||||
let mut s = session();
|
||||
s.execute(C, &json!({"resolution": "high"})).unwrap();
|
||||
let r = red_rectangle(&mut s);
|
||||
let before = s.doc().unwrap().doc.node(r).unwrap().path_data().cloned();
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": effect, "ids": [r.0]})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let n = d.node(r).unwrap();
|
||||
assert_eq!((n.path_data().cloned(), n.appearance.effects[0].id.as_str()), (before, "rasterize"), "the path stays as it was");
|
||||
// Saved and read back.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap();
|
||||
assert_eq!(back.node(r).unwrap().appearance.effects, n.appearance.effects);
|
||||
// Object › Rasterize of the same rectangle.
|
||||
let mut o = session();
|
||||
o.execute(C, &json!({"resolution": "high"})).unwrap();
|
||||
red_rectangle(&mut o);
|
||||
let made = id(&o.execute("object.rasterize", &object).unwrap());
|
||||
let od = &o.doc().unwrap().doc;
|
||||
let want = rasterized(od, od.node(made).unwrap());
|
||||
// PDF export draws that image in its place.
|
||||
let flat = crate::flatten_raster_effects(&d).unwrap();
|
||||
assert_eq!(rasterized(&flat, &flat.layers[0].children().unwrap()[0]), want, "{effect}");
|
||||
// So does Expand Appearance.
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(rasterized(d, d.node(r).unwrap()), want, "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rasterize_effect_follows_edits_to_the_path() {
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"background": "white"}, "ids": [r.0]})).unwrap();
|
||||
let width = |s: &Session| {
|
||||
let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap();
|
||||
rasterized(&flat, &flat.layers[0].children().unwrap()[0]).0
|
||||
};
|
||||
let before = width(&s);
|
||||
// Direct Selection moves the right-hand anchors 20 pt: the image widens with the path.
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 1], [0, 2]]})).unwrap();
|
||||
s.execute("path.moveAnchors", &json!({"dx": 20, "dy": 0})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(r).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Path { .. }) && n.appearance.effects.len() == 1);
|
||||
assert_eq!(width(&s), before + 20, "72 ppi: a pixel a point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_measures_the_art_its_geometry_effects_draw() {
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [r.0]})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"background": "white"}, "ids": [r.0]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let (width, _, xf, ..) = rasterized(d, d.node(r).unwrap());
|
||||
// The rectangle as the Transform effect draws it: 100 pt to the right, 41 pt wide with its stroke.
|
||||
assert_eq!((xf.translation().x, width), (150.0, 41));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipping_mask_follows_the_art_its_geometry_effects_draw() {
|
||||
let clip = |s: &Session, n: NodeId| match &s.doc().unwrap().doc.node(n).unwrap().kind {
|
||||
NodeKind::Group { children, clip: true } => children[0].geometric_bounds(),
|
||||
_ => None,
|
||||
};
|
||||
let moved = |s: &mut Session, n: NodeId| {
|
||||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [n.0]})).unwrap();
|
||||
};
|
||||
// Expand Appearance clips the image to the rectangle drawn 100 pt to the right, from 150.5 to
|
||||
// 190.5 pt.
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
moved(&mut s, r);
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"clippingMask": true}, "ids": [r.0]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
|
||||
let b = clip(&s, r).expect("a clip group");
|
||||
assert!((b.x0 - 150.5).abs() < 1e-6 && (b.x1 - 190.5).abs() < 1e-6, "{b:?}");
|
||||
// Object › Rasterize clips its image there too.
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
moved(&mut s, r);
|
||||
let made = id(&s.execute("object.rasterize", &json!({"clippingMask": true})).unwrap());
|
||||
let b = clip(&s, made).expect("a clip group");
|
||||
assert!((b.x0 - 150.5).abs() < 1e-6 && (b.x1 - 190.5).abs() < 1e-6, "{b:?}");
|
||||
// Type's image is clipped to its glyphs, 100 pt right of where they are without the effect.
|
||||
let glyphs = |effect: bool| {
|
||||
let mut s = session();
|
||||
let t = id(&s.execute("text.create", &json!({"x": 50, "y": 100, "text": "H", "size": 40})).unwrap());
|
||||
if effect {
|
||||
moved(&mut s, t);
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
let made = id(&s.execute("object.rasterize", &json!({"clippingMask": true})).unwrap());
|
||||
clip(&s, made).expect("a clip group")
|
||||
};
|
||||
let (plain, shifted) = (glyphs(false), glyphs(true));
|
||||
assert!((shifted.x0 - plain.x0 - 100.0).abs() < 1e-6 && (shifted.x1 - plain.x1 - 100.0).abs() < 1e-6, "{plain:?} {shifted:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_rasterize_takes_in_everything_an_effect_stack_paints() {
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 4}, "ids": [r.0]})).unwrap();
|
||||
let live = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 10});
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": live, "ids": [r.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let made = id(&s.execute("object.rasterize", &json!({"ppi": 72, "background": "transparent"})).unwrap());
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let (width, height, xf, png, _) = rasterized(d, d.node(made).unwrap());
|
||||
let img = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
// 41 pt of rectangle and stroke, and 6 pt of blur and 10 pt of white on each side: white from
|
||||
// the first column to the last.
|
||||
assert_eq!((width, xf.translation().x), (73, 34.0));
|
||||
assert_eq!((img.get_pixel(0, height / 2).0, img.get_pixel(width - 1, height / 2).0), ([255; 4], [255; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_settings_round_trip_and_old_files_load() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
|
||||
@@ -1020,3 +1020,34 @@ fn overflow_marker_shows_with_the_selection_tool() {
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert_eq!(red(&mut s), 0, "nothing selected");
|
||||
}
|
||||
|
||||
/// #2572: with the Selection tool, a click on the out port and a click on empty canvas thread the
|
||||
/// overflow into a new frame there, in one undo step; Escape, or another tool, drops the loaded text.
|
||||
#[test]
|
||||
fn clicking_the_out_port_then_the_canvas_threads_the_text_into_a_new_frame() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let long = "Words that fill more than the frame holds. ".repeat(20);
|
||||
let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let port = Point::new(150.0, 90.0 - vectorcraft_tools::text::PORT_RISE_PX);
|
||||
let loaded = |s: &mut Session| s.cursor(Point::new(400.0, 400.0), Mods::default(), v) == vectorcraft_tools::Cursor::LoadedText;
|
||||
click(&mut s, port.x, port.y);
|
||||
assert!(loaded(&mut s));
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert!(!loaded(&mut s), "Escape drops the text");
|
||||
click(&mut s, port.x, port.y);
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert!(!loaded(&mut s), "another tool drops the text");
|
||||
let steps = undo_labels(&s).len();
|
||||
click(&mut s, port.x, port.y);
|
||||
click(&mut s, 400.0, 400.0);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let [thread] = &doc.text_threads[..] else { panic!("one thread: {:?}", doc.text_threads) };
|
||||
assert_eq!(thread[0], id);
|
||||
assert_eq!(format!("{}{}", obj(&s, id).plain_text(), obj(&s, thread[1]).plain_text()), long);
|
||||
assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]);
|
||||
}
|
||||
@@ -368,6 +368,7 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
|
||||
"kerning",
|
||||
"writing-mode",
|
||||
"font-weight",
|
||||
"font-variation-settings",
|
||||
"text-anchor",
|
||||
"side",
|
||||
"path",
|
||||
@@ -393,6 +394,7 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
|
||||
"onclick",
|
||||
"onload",
|
||||
]);
|
||||
let axes = prop::sample::select(vec!["wght", "wdth", "opsz", "wd", "slnt!", ""]);
|
||||
let value = prop_oneof![
|
||||
arb_num(),
|
||||
prop::collection::vec(arb_num(), 0..7).prop_map(|v| v.join(" ")),
|
||||
@@ -401,6 +403,9 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
|
||||
prop::collection::vec((prop::sample::select(vec!["M", "L", "C", "Q", "A", "Z", "H", "V", "S", "T", "m", "a"]), arb_num()), 0..12)
|
||||
.prop_map(|v| v.iter().map(|(c, n)| format!("{c}{n} {n}")).collect::<Vec<_>>().join(" ")),
|
||||
prop::collection::vec(arb_num(), 0..2).prop_map(|v| format!("{}%", v.join(""))),
|
||||
// Variable font axis settings, well formed or not.
|
||||
prop::collection::vec((axes, arb_num()), 0..5).prop_map(|v| v.iter().map(|(t, n)| format!("'{t}' {n}")).collect::<Vec<_>>().join(", ")),
|
||||
prop::sample::select(vec!["normal", "initial", "unset"]).prop_map(str::to_string),
|
||||
// Colours (Lab too), mask options and links.
|
||||
prop::collection::vec(arb_num(), 0..4).prop_map(|v| format!("lab({})", v.join(" "))),
|
||||
prop::sample::select(vec!["noclip invert", "invert", "noclip", "alpha", "luminance", "url(#e)", "#e", "#zz", "none", "currentColor"])
|
||||
|
||||
@@ -80,7 +80,7 @@ const HEX_LINE: usize = 64;
|
||||
|
||||
pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)";
|
||||
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines";
|
||||
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, area type options, type on a path effects, space after paragraphs, tabs) are left out";
|
||||
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs) are left out";
|
||||
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
|
||||
pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)";
|
||||
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
|
||||
@@ -1279,7 +1279,13 @@ fn writable(t: &TextObject) -> bool {
|
||||
/// Has type `t` attributes a legacy `.ai` text object can't hold (lost when it's written as text)?
|
||||
fn lost_attributes(t: &TextObject) -> bool {
|
||||
let run_lost = |s: &CharStyle| {
|
||||
s.underline || s.strikethrough || s.small_caps.is_some() || s.position != CharPosition::Normal || s.rotation != 0.0 || !s.features.is_empty()
|
||||
s.underline
|
||||
|| s.strikethrough
|
||||
|| s.small_caps.is_some()
|
||||
|| s.position != CharPosition::Normal
|
||||
|| s.rotation != 0.0
|
||||
|| !s.features.is_empty()
|
||||
|| !s.font_axes.is_empty()
|
||||
};
|
||||
let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty();
|
||||
t.runs.iter().any(|r| run_lost(&r.style))
|
||||
|
||||
@@ -516,6 +516,21 @@ fn type_is_written_as_text_objects() {
|
||||
assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
/// A legacy `.ai` text object names a font, not a setting of its variation axes: type with
|
||||
/// Variable Font settings is written in the font's style, with the warning that they are left out.
|
||||
#[test]
|
||||
fn variable_font_settings_are_warned_as_left_out() {
|
||||
let mut doc = typed();
|
||||
assert!(!write(&doc).warnings.iter().any(|w| w == TYPE_ATTRIBUTES));
|
||||
let id = texts(&doc)[0].0.id;
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs[0].style.font_axes.insert("wght".into(), 650.0);
|
||||
}
|
||||
let out = write(&doc);
|
||||
assert!(out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES), "{:?}", out.warnings);
|
||||
assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_reads_back_as_text() {
|
||||
use vectorcraft_doc::{Justify, TextKind};
|
||||
|
||||
@@ -951,8 +951,17 @@ fn mean_scale(a: Affine) -> f64 {
|
||||
a.determinant().abs().sqrt()
|
||||
}
|
||||
|
||||
/// Width a PDF line width of 0 imports as, points: a print hairline. In PDF `0 w` is the thinnest
|
||||
/// line the device can draw (ISO 32000-1, 8.4.3.2), not no line.
|
||||
const HAIRLINE: f64 = 0.25;
|
||||
|
||||
/// Document width of the stroke `p` drawn at `scale`: `0 w` becomes a [`HAIRLINE`].
|
||||
fn line_width(p: &StrokeProps, scale: f64) -> f64 {
|
||||
if p.line_width == 0.0 { HAIRLINE } else { p.line_width as f64 * scale }
|
||||
}
|
||||
|
||||
fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer {
|
||||
let mut st = StrokeLayer::new(paint, p.line_width as f64 * scale);
|
||||
let mut st = StrokeLayer::new(paint, line_width(p, scale));
|
||||
st.opacity = opacity;
|
||||
st.cap = match p.line_cap {
|
||||
kurbo::Cap::Butt => LineCap::Butt,
|
||||
@@ -1733,7 +1742,7 @@ impl<'p> Builder<'p> {
|
||||
None => named(&info.face),
|
||||
};
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true));
|
||||
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
|
||||
let stroke = stroke.map(|p| (paint.clone(), line_width(p, scale)));
|
||||
let look = Look {
|
||||
font: key,
|
||||
family: info.family,
|
||||
@@ -1850,7 +1859,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let (paint, opacity) = self.paint(paint, stroke, walked);
|
||||
// A gradient that doesn't extend past its ends paints only between them.
|
||||
let width = match draw_mode {
|
||||
PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
|
||||
PathDrawMode::Stroke(p) => line_width(p, mean_scale(transform)),
|
||||
PathDrawMode::Fill(_) => 0.0,
|
||||
};
|
||||
let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width)));
|
||||
|
||||
@@ -644,3 +644,16 @@ fn clips_with_the_page_as_bounding_box_still_clip() {
|
||||
assert!(!clipped("0 0 100 100 re"), "the page");
|
||||
assert!(!clipped("50 -60 m 160 50 l 50 160 l -60 50 l h"), "a turned square around the page");
|
||||
}
|
||||
|
||||
/// Issue #2674: `0 w` is the thinnest line the device can draw (ISO 32000-1, 8.4.3.2), not no
|
||||
/// line: it imports as a visible hairline and survives a PDF round trip.
|
||||
#[test]
|
||||
fn zero_width_lines_import_as_hairlines() {
|
||||
let d = open(&one_page("0 0 0 RG 0 w 10 50 m 90 50 l S 0.9 g 0 w 20 60 30 20 re b 1 w 10 20 m 90 20 l S", "", &[]));
|
||||
let widths = |d: &Document| -> Vec<f64> { all(d).iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect() };
|
||||
let w = widths(&d);
|
||||
assert_eq!(w.len(), 3, "{w:?}");
|
||||
assert!(w.iter().all(|w| *w > 0.0 && *w <= 1.0), "zero width strokes become hairlines: {w:?}");
|
||||
let back = widths(&roundtrip(&d));
|
||||
assert_eq!(back.len(), 3, "hairlines survive the PDF round trip: {back:?}");
|
||||
}
|
||||
@@ -205,6 +205,29 @@ fn variable_font_instances_are_real_text_in_their_own_glyphs() {
|
||||
assert!(widths[1] > widths[0] * 1.2, "Bold is wider: {widths:?}");
|
||||
}
|
||||
|
||||
/// Type with Variable Font settings is real text in a font embedded at those settings: it draws
|
||||
/// where its outlines would, between the named instances on either side.
|
||||
#[test]
|
||||
fn variable_font_settings_are_real_text_in_their_own_glyphs() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, VARIABLE_CHARS, two_axis_font};
|
||||
FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let mut widths = vec![];
|
||||
for (s, wght) in [("Regular", None), ("Regular", Some(650.0)), ("Bold", None)] {
|
||||
let font_axes = wght.map(|w| [("wght".to_string(), w)].into_iter().collect()).unwrap_or_default();
|
||||
let st = CharStyle { font_family: TWO_AXIS_FAMILY.into(), font_style: s.into(), font_axes, ..style(48.0) };
|
||||
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
|
||||
let r = pdf(&d, false);
|
||||
assert!(r.warnings.is_empty(), "{s} {wght:?}: {:?}", r.warnings);
|
||||
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s} {wght:?}: real text");
|
||||
let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
|
||||
let off =
|
||||
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(off < 0.5, "{s} {wght:?}: {real:?} vs {outlined:?}");
|
||||
widths.push(real.width());
|
||||
}
|
||||
assert!(widths[0] * 1.1 < widths[1] && widths[1] < widths[2], "{widths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_on_real_text_stay_where_they_were() {
|
||||
let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
|
||||
@@ -31,6 +31,8 @@ vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
# A variable font built in code (no font file is committed).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
# Live-effect plug-ins the cache tests install, written in WebAssembly text.
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
|
||||
+532
-43
@@ -17,6 +17,12 @@
|
||||
//! filters, Gaussian Blur and Feather, see [`effects::pixel`]), then drawn back
|
||||
//! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't
|
||||
//! change.
|
||||
//! - Rasterize: the content drawn again on the image's own grid, as Object › Rasterize draws it
|
||||
//! ([`Renderer::rasterize_image`]), then into the content's raster
|
||||
//! ([`effects::Rasterize::compose`]; an image too large for that is made from the content's
|
||||
//! raster, [`effects::Rasterize::apply`]), clipped to its outline with Create Clipping Mask. The
|
||||
//! effects before it in the stack, and the shadows and glows after it, are drawn around it by
|
||||
//! splitting the stack there ([`Renderer::raster_fx`]).
|
||||
//!
|
||||
//! While [`crate::RenderOptions::interacting`], an object whose content only moved draws its
|
||||
//! cached shadows, glows and filtered content at its new place instead of running the effects
|
||||
@@ -110,6 +116,41 @@ struct Content<'a> {
|
||||
/// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and
|
||||
/// Inner Glow is skipped).
|
||||
outline: Option<(&'a BezPath, FillRule)>,
|
||||
/// What Rasterize makes its image over: the object's [`drawn_bounds`] as far as the effects
|
||||
/// before it spread them (`None`: `reach`).
|
||||
bounds: Option<Rect>,
|
||||
/// Where the image of the Rasterize first in these effects is kept for the effects after a
|
||||
/// split ([`Renderer::raster_fx`]), which draw this content more than once.
|
||||
kept: Option<&'a Kept>,
|
||||
}
|
||||
|
||||
/// The image of the last Rasterize before a split in an effect stack ([`Renderer::raster_fx`]). It
|
||||
/// is made the first time the effects after the split draw the content and drawn from here the
|
||||
/// other times, so the content before the split is drawn once.
|
||||
struct Kept {
|
||||
/// Where the effects after the split take the content from: the visible area grown by how
|
||||
/// far they paint.
|
||||
window: Rect,
|
||||
/// The view and pixel size of the split's frame, when the image is over
|
||||
/// [`Renderer::rasterize_image`]'s limits: the content is then drawn in that frame.
|
||||
frame: Option<(Affine, f64)>,
|
||||
image: std::cell::RefCell<Option<KeptImage>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum KeptImage {
|
||||
/// The image on its own grid, as [`Renderer::rasterize_image`] makes it.
|
||||
Grid(std::rc::Rc<(Vec<u8>, usize, usize)>),
|
||||
/// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the
|
||||
/// Rasterize applied, as the first draw made it, and the transform from its pixels to
|
||||
/// document space.
|
||||
Raster(Arc<vello_cpu::Pixmap>, Affine),
|
||||
}
|
||||
|
||||
/// The [`Content::bounds`] of `n` under raster effects `rfx`: its [`drawn_bounds`] when `rfx`
|
||||
/// holds a Rasterize.
|
||||
fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]) -> Option<Rect> {
|
||||
rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| drawn_bounds(symbols, n)).flatten()
|
||||
}
|
||||
|
||||
/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset).
|
||||
@@ -157,7 +198,7 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
|
||||
pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
|
||||
return cull_bounds(symbols, &art).map(|b| {
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
let o = outset(symbols, &n.appearance.effects, b);
|
||||
b.inflate(o, o)
|
||||
});
|
||||
}
|
||||
@@ -178,11 +219,173 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
let s = n.appearance.outset();
|
||||
r.map(|r| {
|
||||
let r = r.inflate(s, s);
|
||||
let o = effects::outset(&n.appearance.effects, r);
|
||||
// The fills' and strokes' own raster effects, then the object's.
|
||||
let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b));
|
||||
let o = outset(symbols, &n.appearance.effects, r);
|
||||
r.inflate(o, o)
|
||||
})
|
||||
}
|
||||
|
||||
/// Does `item` paint (as [`Renderer::paint_items`] draws it)?
|
||||
fn paints(item: &AppearanceItem) -> bool {
|
||||
item.visible() && !item.paint().is_none()
|
||||
}
|
||||
|
||||
/// The bounds of what `n` draws, its own and its members' geometry effects applied and the raster
|
||||
/// effects of its members and of its fills and strokes included (each fill's and stroke's
|
||||
/// spreading what it paints, [`item_bounds`]), with strokes measured as
|
||||
/// [`Node::visual_bounds_with`] measures them, without the reach of `n`'s own raster effects
|
||||
/// (symbol instances measured by their art from `symbols`): what Rasterize makes its image over.
|
||||
pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
let b = art_bounds(symbols, n)?;
|
||||
let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty());
|
||||
Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
|
||||
}
|
||||
|
||||
/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path
|
||||
/// with `n`'s and `item`'s geometry effects, and for a stroke its width. `None` for other objects,
|
||||
/// 3D art, a brushed stroke and an item that draws no path.
|
||||
fn item_bounds(n: &Node, item: &AppearanceItem) -> Option<Rect> {
|
||||
let stroke = matches!(item, AppearanceItem::Stroke(_));
|
||||
if !has_fx(n) || effects::has_revolve(n) || effects::has_solid(n) || (stroke && vectorcraft_brush::has_brush(n)) {
|
||||
return None;
|
||||
}
|
||||
let g = effected_path(n, &node_bezpath(n)?);
|
||||
let fx = item_effects(item);
|
||||
if effects::has_geometry(fx) {
|
||||
let ig = apply(fx, &g, &GeomContext::of(n).item(item));
|
||||
let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
|
||||
return (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o));
|
||||
}
|
||||
let one = vectorcraft_doc::Appearance { items: vec![item.clone()], ..Default::default() };
|
||||
(!g.elements().is_empty()).then(|| one.stroked_bounds(g.bounding_box(), || g.clone()))
|
||||
}
|
||||
|
||||
/// `b` grown by how far raster effects `fx` paint past it, each spreading what the ones before it
|
||||
/// paint (Rasterize at the document's raster effects resolution where it takes it).
|
||||
fn spread(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> Rect {
|
||||
let ppi = doc_raster_ppi(symbols.document());
|
||||
effects::raster_effects(fx).iter().fold(b, |r, e| e.at_document_ppi(ppi).spread(r, 72.0 / ppi))
|
||||
}
|
||||
|
||||
/// [`drawn_bounds`] before the raster effects of `n`'s fills and strokes: its art, and the art of
|
||||
/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches).
|
||||
fn art_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
let b = shape_bounds(symbols, n);
|
||||
let brushed = vectorcraft_brush::has_brush(n)
|
||||
.then(|| vectorcraft_brush::expand(symbols.document(), n))
|
||||
.flatten()
|
||||
.and_then(|art| art.visual_bounds_with(symbols));
|
||||
let b = vectorcraft_geom::union_opt(b, brushed)?;
|
||||
let pad = if matches!(n.kind, NodeKind::SymbolInstance { .. }) { crate::brush_fx::pad(symbols, n) } else { 0.0 };
|
||||
Some(b.inflate(pad, pad))
|
||||
}
|
||||
|
||||
/// [`art_bounds`] without brush art.
|
||||
fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
// The visible members of `g` (a Shaper group's first member is its source art, which paints
|
||||
// nothing).
|
||||
let union = |g: &Node| {
|
||||
let members = g.children()?.get(usize::from(g.extras().shaper.is_some())..).unwrap_or_default();
|
||||
members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
|
||||
};
|
||||
match &n.kind {
|
||||
_ if effects::has_container_appearance(n) => {
|
||||
let art = effects::evaluate_container(n);
|
||||
let art = art.as_ref().unwrap_or(n);
|
||||
if art.clips() { art.visual_bounds_with(symbols) } else { union(art) }
|
||||
}
|
||||
NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n),
|
||||
_ if has_fx(n) => {
|
||||
// A 3D effect's art.
|
||||
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
|
||||
return drawn_bounds(symbols, &art);
|
||||
}
|
||||
let g = effected_path(n, &node_bezpath(n)?);
|
||||
// The path as the fills and strokes without geometry effects of their own paint it
|
||||
// (as every fill and stroke would when none paints).
|
||||
let mut plain = n.appearance.clone();
|
||||
plain.items.retain(|i| !(paints(i) && effects::has_geometry(item_effects(i))));
|
||||
let on_path = !n.appearance.items.iter().any(paints) || plain.items.iter().any(paints);
|
||||
let mut r = (on_path && !g.elements().is_empty()).then(|| plain.stroked_bounds(g.bounding_box(), || g.clone()));
|
||||
let ctx = GeomContext::of(n);
|
||||
for item in n.appearance.items.iter().filter(|i| paints(i) && effects::has_geometry(item_effects(i))) {
|
||||
let ig = apply(item_effects(item), &g, &ctx.item(item));
|
||||
let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
|
||||
r = vectorcraft_geom::union_opt(r, (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o)));
|
||||
}
|
||||
r
|
||||
}
|
||||
// As [`cull_bounds_in`] measures them, without the object's own raster effects: the
|
||||
// reshaped art (type's outlines measured one by one with the effects of its own fills and
|
||||
// strokes), grown by how far the effects in a symbol's art and a live object's content
|
||||
// paint past it.
|
||||
_ if has_object_fx(n) => {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let reshaped = effects::reshape(n, symbol.as_deref());
|
||||
let b = match &reshaped {
|
||||
Some(r) if matches!(n.kind, NodeKind::Text(_)) => {
|
||||
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
|
||||
}
|
||||
Some(r) => r.visual_bounds_with(symbols),
|
||||
None => None,
|
||||
}
|
||||
.or_else(|| n.visual_bounds_with(symbols))?;
|
||||
let a = match &reshaped {
|
||||
Some(r) => reshaped_art_effects_reach(symbols, n, r),
|
||||
None => art_effects_reach(symbols, n),
|
||||
};
|
||||
let a = a.max(live_content_reach(symbols, n));
|
||||
Some(b.inflate(a, a))
|
||||
}
|
||||
_ => {
|
||||
let b = n.visual_bounds_with(symbols)?;
|
||||
let a = art_effects_reach(symbols, n).max(live_content_reach(symbols, n));
|
||||
Some(b.inflate(a, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`drawn_bounds`] of `n` grown by how far its own raster effects paint past them. With Crop
|
||||
/// Marks, the [`drawn_bounds`] of its art: the marks and the object, which paints its own raster
|
||||
/// effects inside the art.
|
||||
pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
if let Some(art) = effects::crop_marks_art(n) {
|
||||
return drawn_bounds(symbols, &art);
|
||||
}
|
||||
drawn_bounds(symbols, n).map(|b| spread(symbols, &n.appearance.effects, b))
|
||||
}
|
||||
|
||||
/// The outline the renderer clips `n`'s Feather, Inner Glow and Rasterize with Create Clipping
|
||||
/// Mask to: a path's or compound path's geometry with its object-level geometry effects applied,
|
||||
/// the outline of the art [`Renderer::fx_art`] makes for an object with object effects, and with
|
||||
/// Crop Marks the object's own. For other objects it is [`effects::clip_outline`].
|
||||
pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
|
||||
if let Some(art) = effects::crop_marks_art(n) {
|
||||
return drawn_outline(doc, art.children()?.first()?);
|
||||
}
|
||||
if has_object_fx(n) {
|
||||
let art = Renderer::new().fx_art(doc, &Arc::new(n.clone()), false);
|
||||
return effects::clip_outline(&art);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } if effects::has_geometry(&n.appearance.effects) => {
|
||||
Some((effected_path(n, &node_bezpath(n)?), *rule))
|
||||
}
|
||||
_ => effects::clip_outline(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// How far raster effects `fx` paint beyond `b`, each spreading what the ones before it paint
|
||||
/// ([`spread`]).
|
||||
fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 {
|
||||
let s = spread(symbols, fx, b);
|
||||
[b.x0 - s.x0, b.y0 - s.y0, s.x1 - b.x1, s.y1 - b.y1].into_iter().fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Bounds used for culling: like `Node::visual_bounds_with`, but aware of live effects anywhere in
|
||||
/// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are
|
||||
/// measured by their art from `symbols`.
|
||||
@@ -209,7 +412,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
} else {
|
||||
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
|
||||
}?;
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
let o = outset(symbols, &n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
@@ -239,7 +442,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
};
|
||||
let a = a.max(live_content_reach(symbols, n));
|
||||
let b = b.inflate(a, a);
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
let o = outset(symbols, &n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
}
|
||||
NodeKind::SymbolInstance { .. } => {
|
||||
@@ -351,10 +554,43 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
|
||||
/// cropped to the view when its effects only look nearby, else rendered at a lower resolution.
|
||||
const PIXEL_BUDGET: f64 = 8.4e6;
|
||||
|
||||
/// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each
|
||||
/// draws the content again inside the one after it.
|
||||
const MAX_RASTERIZE: usize = 16;
|
||||
|
||||
/// The columns and rows of the image Rasterize `r` makes over `rect` (whole pixels of the image,
|
||||
/// `line` points wide at the document's raster effects resolution). `None` when the image would
|
||||
/// hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than [`crate::MAX_RASTER_SIDE`].
|
||||
fn image_size(r: &effects::Rasterize, rect: Rect, line: f64) -> Option<(u16, u16)> {
|
||||
let cell = r.cell(line);
|
||||
let (cols, rows) = ((rect.width() / cell).round(), (rect.height() / cell).round());
|
||||
let side = f64::from(crate::MAX_RASTER_SIDE);
|
||||
(cols >= 1.0 && rows >= 1.0 && cols <= side && rows <= side && cols * rows <= PIXEL_BUDGET).then_some((cols as u16, rows as u16))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times a Rasterize has drawn the content before it on this thread.
|
||||
pub(crate) static RASTERIZED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a
|
||||
/// number).
|
||||
fn doc_raster_ppi(doc: &Document) -> f64 {
|
||||
Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
}
|
||||
|
||||
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
|
||||
/// content itself), in stack order.
|
||||
fn content_changing(fx: &&RasterFx) -> bool {
|
||||
matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_))
|
||||
matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_) | RasterFx::Rasterize(_))
|
||||
}
|
||||
|
||||
/// Is the content drawn with hard edges for `chain` into a raster of pixels `px` wide (document
|
||||
/// units): does it go first through a Rasterize with anti-aliasing off whose pixels are no
|
||||
/// smaller? (Smaller ones show as the share of each raster pixel they cover, as smooth edges do.)
|
||||
fn hard_edged(chain: &[&RasterFx], px: f64) -> bool {
|
||||
matches!(chain.first(), Some(RasterFx::Rasterize(r)) if !r.anti_alias && r.cell(72.0) >= px)
|
||||
}
|
||||
|
||||
/// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when
|
||||
@@ -363,6 +599,7 @@ fn looks_around(fx: &RasterFx) -> Option<f64> {
|
||||
match fx {
|
||||
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius),
|
||||
RasterFx::Pixel(p) => p.reach(),
|
||||
RasterFx::Rasterize(r) => Some(r.reach()),
|
||||
_ => Some(0.0),
|
||||
}
|
||||
}
|
||||
@@ -442,6 +679,23 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
/// Draw `image` with its pixels placed by `xf` (pixels to device pixels). On whole pixels at full
|
||||
/// size it is drawn as [`draw_scaled`] draws it.
|
||||
fn draw_placed(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, xf: Affine) {
|
||||
let [a, b, c, d, e, f] = xf.as_coeffs();
|
||||
let near = |v: f64, to: f64| (v - to).abs() < 1e-6;
|
||||
if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) {
|
||||
return draw_scaled(ctx, image, (e.round(), f.round()), 1.0);
|
||||
}
|
||||
let (w, h) = (image.width() as f64, image.height() as f64);
|
||||
ctx.set_transform(xf);
|
||||
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() };
|
||||
ctx.reset_paint_transform();
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to
|
||||
/// the view-space position of `anchor` (a document point).
|
||||
pub(crate) struct ShadowEntry {
|
||||
@@ -939,7 +1193,10 @@ impl Renderer {
|
||||
// Document-space reach of the painted geometry (strokes, arrowheads…).
|
||||
let outset = n.appearance.outset();
|
||||
let reach = g.bounding_box().inflate(outset, outset);
|
||||
let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)) };
|
||||
let bounds = rasterize_bounds(f.symbols, n, &rfx);
|
||||
// Under Rasterize the content reaches as far as its fills' and strokes' raster effects paint.
|
||||
let reach = bounds.map_or(reach, |b| reach.union(b));
|
||||
let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None };
|
||||
let mut items = items;
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut()));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
@@ -973,12 +1230,16 @@ impl Renderer {
|
||||
let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) };
|
||||
match cull_bounds(f.symbols, &art) {
|
||||
Some(reach) if !f.opts.outline && !rfx.is_empty() => {
|
||||
// Feather and Inner Glow clip to the art's outline.
|
||||
let clipped = rfx.iter().any(|x| matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }));
|
||||
// Feather, Inner Glow and Rasterize with Create Clipping Mask clip to the art's outline.
|
||||
let clipped = rfx.iter().any(|x| {
|
||||
matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }) || matches!(x, RasterFx::Rasterize(r) if r.clipping_mask)
|
||||
});
|
||||
let region = if clipped { self.clip_of(&art) } else { None };
|
||||
// A symbol's art can change while the instance doesn't: no cached shadows.
|
||||
let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0);
|
||||
let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) };
|
||||
let bounds = rasterize_bounds(f.symbols, a, &rfx);
|
||||
let reach = bounds.map_or(reach, |b| reach.union(b));
|
||||
let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None };
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
@@ -1093,6 +1354,102 @@ impl Renderer {
|
||||
rfx: &[RasterFx],
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
// Only the last MAX_RASTERIZE Rasterize effects are drawn.
|
||||
let count = rfx.iter().filter(|fx| matches!(fx, RasterFx::Rasterize(_))).count();
|
||||
let capped: Vec<RasterFx>;
|
||||
let rfx = if count > MAX_RASTERIZE {
|
||||
let mut left_out = count - MAX_RASTERIZE;
|
||||
capped = rfx
|
||||
.iter()
|
||||
.filter(|fx| {
|
||||
let out = left_out > 0 && matches!(fx, RasterFx::Rasterize(_));
|
||||
left_out -= usize::from(out);
|
||||
!out
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
&capped[..]
|
||||
} else {
|
||||
rfx
|
||||
};
|
||||
// Rasterize at the document's raster effects resolution takes it here.
|
||||
let resolved: Vec<RasterFx>;
|
||||
let rfx = if rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(r) if r.ppi.is_none())) {
|
||||
let ppi = doc_raster_ppi(f.doc);
|
||||
resolved = rfx.iter().map(|fx| fx.at_document_ppi(ppi)).collect();
|
||||
&resolved[..]
|
||||
} else {
|
||||
rfx
|
||||
};
|
||||
// Rasterize makes an image of everything the effects before it paint, shadows and glows
|
||||
// included, and the shadows and glows after it paint around that image. When some come
|
||||
// after the last Rasterize, the stack splits after it, else when any effect comes before
|
||||
// it, the stack splits before it; the effects after the split paint around the content
|
||||
// drawn with those before it. Rasterize then comes first in its part.
|
||||
if let Some(at) = rfx.iter().rposition(|fx| matches!(fx, RasterFx::Rasterize(_))) {
|
||||
let paints_around = |fx: &[RasterFx]| fx.iter().any(|fx| !content_changing(&fx));
|
||||
let (_, rest) = rfx.split_at(at + 1);
|
||||
let split = if paints_around(rest) { Some(at + 1) } else { (at > 0).then_some(at) };
|
||||
if let Some(split) = split {
|
||||
let (before, after) = rfx.split_at(split);
|
||||
let line = 72.0 / doc_raster_ppi(f.doc);
|
||||
let (mut reach, mut spread) = (content.reach, content.bounds);
|
||||
// After a Rasterize on white without a clipping mask, the silhouette is its image,
|
||||
// and it stays the silhouette through the effects after it (as in `pixel_content`).
|
||||
let mut image = None;
|
||||
// Whether the image of a Rasterize before the split is over `rasterize_image`'s
|
||||
// limits. Such an image is kept only as far as the view needs it, so the effects
|
||||
// after the split are not cached.
|
||||
let (mut over, mut last_over) = (false, false);
|
||||
for fx in before {
|
||||
if let RasterFx::Rasterize(r) = fx {
|
||||
last_over = image_size(r, r.image(spread.unwrap_or(reach), line), line).is_none();
|
||||
over |= last_over;
|
||||
}
|
||||
let o = fx.outset(reach);
|
||||
reach = reach.inflate(o, o);
|
||||
spread = spread.map(|b| fx.spread(b, line));
|
||||
if let RasterFx::Rasterize(r) = fx
|
||||
&& r.white
|
||||
&& !r.clipping_mask
|
||||
{
|
||||
image = Some(spread.unwrap_or(reach).to_path(0.1));
|
||||
}
|
||||
}
|
||||
// When the split comes after the Rasterize, the shadows and glows after it and the
|
||||
// content each draw the content before the split. The first draw makes the image,
|
||||
// and the others draw the one it keeps.
|
||||
let window = after.iter().fold(f.visible, |w, fx| {
|
||||
let o = fx.outset(w);
|
||||
w.inflate(o, o)
|
||||
});
|
||||
let frame = last_over.then_some((f.view, f.px));
|
||||
let kept = (split > at).then(|| Kept { window, frame, image: std::cell::RefCell::new(None) });
|
||||
let inner = Content {
|
||||
node: content.node,
|
||||
owner: content.owner,
|
||||
cache: None,
|
||||
reach: content.reach,
|
||||
outline: content.outline,
|
||||
bounds: content.bounds,
|
||||
kept: kept.as_ref(),
|
||||
};
|
||||
let outline = image.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline);
|
||||
// When the split comes before the Rasterize, the part from it on draws the content
|
||||
// once, and its image is the one kept for a split above.
|
||||
let outer = Content {
|
||||
node: content.node,
|
||||
owner: content.owner,
|
||||
cache: content.cache.filter(|_| !over),
|
||||
reach,
|
||||
outline,
|
||||
bounds: spread,
|
||||
kept: content.kept.filter(|_| split == at),
|
||||
};
|
||||
self.raster_fx(ctx, f, &outer, after, &mut |r, c, fr| r.raster_fx(c, fr, &inner, before, paint));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let reach = content.reach;
|
||||
// The `Arc` drawn: the caller's, or the node `draw_arc` is drawing.
|
||||
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
|
||||
@@ -1134,7 +1491,7 @@ impl Renderer {
|
||||
// works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel
|
||||
// and looked blocky as the view moved by parts of a pixel (#755).
|
||||
let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0));
|
||||
if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) {
|
||||
if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) {
|
||||
self.pixel_content(ctx, f, content, rfx, paint);
|
||||
} else {
|
||||
paint(self, ctx, f);
|
||||
@@ -1176,11 +1533,15 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather and the
|
||||
/// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can
|
||||
/// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels:
|
||||
/// larger content is cropped to the view when every effect only looks nearby, else rendered
|
||||
/// at a lower resolution. A whole raster at full resolution is cached while the object, the
|
||||
/// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather, the
|
||||
/// Photoshop-style filters and Rasterize) in stack order on its pixels: rendered offscreen over
|
||||
/// all it can reach, filtered on the CPU and drawn back. A Rasterize first in the chain draws the
|
||||
/// content on its image's own grid instead ([`Self::rasterize_image`]) when that image is within
|
||||
/// the limits below; content with a [`Kept`] image draws that image after its first draw. The
|
||||
/// raster holds at most [`PIXEL_BUDGET`] pixels and [`crate::MAX_RASTER_SIDE`] a side: content
|
||||
/// over the budget is cropped to the view (content with a [`Kept`] image to its window) when
|
||||
/// every effect only looks nearby, and content still over either limit is rendered at a lower
|
||||
/// resolution. A whole raster at full resolution is cached while the object, the
|
||||
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
|
||||
/// while interacting after the content only moved ([`Self::move_pixel_content`]).
|
||||
fn pixel_content(
|
||||
@@ -1192,26 +1553,42 @@ impl Renderer {
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect();
|
||||
// When the first draw of this content kept its raster, that raster is drawn.
|
||||
if let Some(KeptImage::Raster(image, place)) = content.kept.and_then(|k| k.image.borrow().clone()) {
|
||||
draw_placed(ctx, image, f.view * place);
|
||||
return;
|
||||
}
|
||||
// Content kept with an image over the limits is drawn in the split's frame, over where the
|
||||
// effects after the split take it from, then placed in this one.
|
||||
let call = f.view;
|
||||
let split_frame;
|
||||
let (f, screen) = match content.kept.and_then(|k| k.frame.map(|frame| (k.window, frame))) {
|
||||
Some((window, (view, px))) => {
|
||||
split_frame = Frame { view, px, ..*f };
|
||||
(&split_frame, view.transform_rect_bbox(window))
|
||||
}
|
||||
None => (f, Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64)),
|
||||
};
|
||||
// Where the result can paint: each effect spreads what the ones before it painted.
|
||||
let reach = chain.iter().fold(content.reach, |r, fx| {
|
||||
let o = fx.outset(r);
|
||||
r.inflate(o, o)
|
||||
});
|
||||
let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0);
|
||||
let (sw, sh) = (ctx.width() as f64, ctx.height() as f64);
|
||||
if !(full.area().is_finite() && full.x1 > 0.0 && full.y1 > 0.0 && full.x0 < sw && full.y0 < sh) {
|
||||
if !(full.area().is_finite() && full.x1 > screen.x0 && full.y1 > screen.y0 && full.x0 < screen.x1 && full.y0 < screen.y1) {
|
||||
return;
|
||||
}
|
||||
let fits = full.area() <= PIXEL_BUDGET;
|
||||
// Device pixels from a pixel to the content deciding it (`None`: anywhere).
|
||||
let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px));
|
||||
let region = match margin {
|
||||
Some(m) if !fits => full.intersect(Rect::new(0.0, 0.0, sw, sh).inflate(m, m)),
|
||||
Some(m) if !fits => full.intersect(screen.inflate(m, m)),
|
||||
_ => full,
|
||||
};
|
||||
let (x0, y0) = (region.x0.floor(), region.y0.floor());
|
||||
let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0));
|
||||
let side = u16::MAX as f64;
|
||||
// (vello_cpu panics drawing near `u16::MAX` pixels a side.)
|
||||
let side = f64::from(crate::MAX_RASTER_SIDE);
|
||||
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
|
||||
// The cached raster, when the whole content was rendered at full size at this zoom.
|
||||
@@ -1225,12 +1602,12 @@ impl Renderer {
|
||||
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
|
||||
};
|
||||
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
|
||||
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
|
||||
let ppi = doc_raster_ppi(f.doc);
|
||||
// What the content reads (as the raster kept for it found it, else found now), with the
|
||||
// channels the effects run on and, under De-Interlace, the resolution.
|
||||
// channels the effects run on and, under De-Interlace or Rasterize, the resolution.
|
||||
let inputs = key.map(|key| {
|
||||
let reads = self.pixel_fx.get(&key).filter(|e| e.node.is(content.node)).map_or_else(|| Reads::of(content.node), |e| e.inputs.reads);
|
||||
let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. })));
|
||||
let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }) | RasterFx::Rasterize(_)));
|
||||
self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits())))
|
||||
});
|
||||
let interacting = f.opts.interacting;
|
||||
@@ -1256,15 +1633,45 @@ impl Renderer {
|
||||
let to_doc = view.inverse();
|
||||
let px = f.px / k;
|
||||
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
|
||||
let mut off = f.offscreen_context(wu, hu);
|
||||
let line = 72.0 / ppi;
|
||||
let (before, decoded) = (self.stats.moved, crate::paint::decode_failures());
|
||||
self.inside_layer(|r| paint(r, &mut off, &frame));
|
||||
// A Rasterize first in the chain draws the content on its image's own grid instead, unless
|
||||
// that image is too large: the content is drawn once.
|
||||
let mut rasterized = match chain.first() {
|
||||
Some(RasterFx::Rasterize(r)) => {
|
||||
let rect = r.image(content.bounds.unwrap_or(content.reach), line);
|
||||
let kept = content.kept.and_then(|k| match &*k.image.borrow() {
|
||||
Some(KeptImage::Grid(image)) => Some(image.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let image = kept.or_else(|| {
|
||||
let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new);
|
||||
if let (Some(k), Some(image)) = (content.kept, &image) {
|
||||
*k.image.borrow_mut() = Some(KeptImage::Grid(image.clone()));
|
||||
}
|
||||
image
|
||||
});
|
||||
image.map(|image| (image, rect))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let mut pm = vello_cpu::Pixmap::new(wu, hu);
|
||||
if rasterized.is_none() {
|
||||
#[cfg(test)]
|
||||
if matches!(chain.first(), Some(RasterFx::Rasterize(_))) {
|
||||
RASTERIZED.with(|n| n.set(n.get() + 1));
|
||||
}
|
||||
let mut off = f.offscreen_context(wu, hu);
|
||||
if hard_edged(&chain, px) {
|
||||
off.set_aliasing_threshold(crate::AntiAlias::None.threshold());
|
||||
}
|
||||
self.inside_layer(|r| paint(r, &mut off, &frame));
|
||||
off.flush();
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
}
|
||||
let failed = crate::paint::decode_failures() - decoded;
|
||||
// Content drawn over moved rasters makes a moved raster.
|
||||
let moved = self.stats.moved > before;
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(wu, hu);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
// What the raster is made from, for its object once it moves (`move_pixel_content`).
|
||||
let made = key.map(|_| Made {
|
||||
pixels: pm.data_as_u8_slice().to_vec(),
|
||||
@@ -1274,18 +1681,41 @@ impl Renderer {
|
||||
outline: feather_outline(&chain, content, content.reach.origin()),
|
||||
});
|
||||
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
|
||||
let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent };
|
||||
let space = effects::PixelSpace { to_doc, px, center, channels, line, extent };
|
||||
let (wz, hz) = (wu as usize, hu as usize);
|
||||
for fx in chain {
|
||||
// What the content covers after the effects run so far: Rasterize makes its image over it.
|
||||
let mut bounds = content.bounds.unwrap_or(content.reach);
|
||||
// The silhouette Feather and Create Clipping Mask clip to: the content's, or after a Rasterize
|
||||
// on white without a clipping mask, its image.
|
||||
let mut image_outline: Option<BezPath> = None;
|
||||
for (i, fx) in chain.into_iter().enumerate() {
|
||||
let before = bounds;
|
||||
bounds = fx.spread(before, space.line);
|
||||
let outline = image_outline.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline);
|
||||
match fx {
|
||||
RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px),
|
||||
RasterFx::Feather { radius } => {
|
||||
effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px);
|
||||
if let Some((g, rule)) = content.outline {
|
||||
if let Some((g, rule)) = outline {
|
||||
self.clip_pixels(&mut pm, &frame, g, rule);
|
||||
}
|
||||
}
|
||||
RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space),
|
||||
RasterFx::Rasterize(r) => {
|
||||
match rasterized.take().filter(|_| i == 0) {
|
||||
Some((image, rect)) => {
|
||||
let (pixels, cols, rows) = &*image;
|
||||
r.compose(pm.data_as_u8_slice_mut(), wz, hz, &space, pixels, *cols, *rows, rect)
|
||||
}
|
||||
None => r.apply(pm.data_as_u8_slice_mut(), wz, hz, &space, before),
|
||||
}
|
||||
if let Some((g, rule)) = outline.filter(|_| r.clipping_mask) {
|
||||
self.clip_pixels(&mut pm, &frame, g, rule);
|
||||
}
|
||||
if r.white && !r.clipping_mask {
|
||||
image_outline = Some(bounds.to_path(0.1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1302,7 +1732,15 @@ impl Renderer {
|
||||
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, failed, stamp });
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
}
|
||||
draw_scaled(ctx, image, (x0, y0), k);
|
||||
// An image over the limits was drawn at a frame's resolution: its raster is kept.
|
||||
match content.kept.filter(|kept| kept.image.borrow().is_none()) {
|
||||
Some(kept) => {
|
||||
let place = f.view.inverse() * Affine::translate((x0, y0)) * Affine::scale(1.0 / k);
|
||||
*kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place));
|
||||
if f.view == call { draw_scaled(ctx, image, (x0, y0), k) } else { draw_placed(ctx, image, call * place) }
|
||||
}
|
||||
None => draw_scaled(ctx, image, (x0, y0), k),
|
||||
}
|
||||
}
|
||||
|
||||
/// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame
|
||||
@@ -1312,7 +1750,7 @@ impl Renderer {
|
||||
/// (and the same Feather outline against its anchor), and whose pixels are the pixels of
|
||||
/// `content` drawn into a raster placed as that one was against its anchor. The raster is kept
|
||||
/// as `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under
|
||||
/// De-Interlace takes none.
|
||||
/// De-Interlace, or under a Rasterize first in the chain, takes none.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn move_pixel_content(
|
||||
&mut self,
|
||||
@@ -1324,7 +1762,8 @@ impl Renderer {
|
||||
inputs: &Inputs,
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) {
|
||||
if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) || matches!(chain.first(), Some(RasterFx::Rasterize(_)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin());
|
||||
@@ -1348,6 +1787,9 @@ impl Renderer {
|
||||
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
|
||||
let frame = Frame { mt: false, view, visible, ..*f };
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
if hard_edged(chain, f.px) {
|
||||
off.set_aliasing_threshold(crate::AntiAlias::None.threshold());
|
||||
}
|
||||
self.inside_layer(|r| paint(r, &mut off, &frame));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
@@ -1363,17 +1805,19 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// The inputs a raster of `n`'s content, which reads `reads`, is drawn with in frame `f`: the
|
||||
/// color settings, the edge smoothing, the knockout `n` inherits and, while `n` holds the
|
||||
/// isolated group, isolation mode; the pattern definitions, symbols and brush library it
|
||||
/// reads; for images and placed documents their pixels and files, the layer's Dim Images and
|
||||
/// the options they are drawn with; for type the fonts, whether overprinting shows
|
||||
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the
|
||||
/// plug-ins installed, when an object in it has a plug-in effect, and when it shows symbol or
|
||||
/// pattern art and an object in the document's symbol or pattern art has one.
|
||||
/// color settings, the edge smoothing, the document's raster effects resolution (which a
|
||||
/// Rasterize or De-Interlace anywhere in the content follows), the knockout `n` inherits and,
|
||||
/// while `n` holds the isolated group, isolation mode; the pattern definitions, symbols and
|
||||
/// brush library it reads; for images and placed documents their pixels and files, the
|
||||
/// layer's Dim Images and the options they are drawn with; for type the fonts, whether
|
||||
/// overprinting shows ([`crate::proof::overprints`]) and Document Setup's substitution
|
||||
/// highlights; and the plug-ins installed, when an object in it has a plug-in effect, and
|
||||
/// when it shows symbol or pattern art and an object in the document's symbol or pattern art
|
||||
/// has one.
|
||||
pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs {
|
||||
let doc = self.doc_inputs(f.doc);
|
||||
let mut h = DefaultHasher::new();
|
||||
(doc.cms, f.opts.anti_alias.threshold(), self.knockout).hash(&mut h);
|
||||
(doc.cms, f.opts.anti_alias.threshold(), self.knockout, f.doc.raster_effects_ppi.to_bits()).hash(&mut h);
|
||||
if let Some((isolated, path)) = self.isolation_path(f)
|
||||
&& n.id != isolated
|
||||
&& path.contains(&n.id)
|
||||
@@ -1466,6 +1910,42 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The image Effect › Rasterize `r` makes of the content `paint` draws over `rect` (whole
|
||||
/// pixels of the image): drawn on the image's own grid as Object › Rasterize draws it (with
|
||||
/// hard edges when `r` doesn't anti-alias), then given `r`'s background and color model. `None`
|
||||
/// when it would hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than
|
||||
/// [`crate::MAX_RASTER_SIDE`]. → (pixels, columns, rows)
|
||||
fn rasterize_image(
|
||||
&mut self,
|
||||
f: &Frame,
|
||||
r: &effects::Rasterize,
|
||||
rect: Rect,
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) -> Option<(Vec<u8>, usize, usize)> {
|
||||
let line = 72.0 / doc_raster_ppi(f.doc);
|
||||
let (w, h) = image_size(r, rect, line)?;
|
||||
#[cfg(test)]
|
||||
RASTERIZED.with(|n| n.set(n.get() + 1));
|
||||
let cell = r.cell(line);
|
||||
let view = Affine::scale(1.0 / cell) * Affine::translate(-rect.origin().to_vec2());
|
||||
// The effect's own anti-aliasing, whatever the view or the export draws with.
|
||||
let anti_alias = match (r.anti_alias, r.hinted) {
|
||||
(false, _) => crate::AntiAlias::None,
|
||||
(true, true) => crate::AntiAlias::Type,
|
||||
(true, false) => crate::AntiAlias::Art,
|
||||
};
|
||||
let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() };
|
||||
let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f };
|
||||
let mut off = frame.offscreen_context(w, h);
|
||||
self.inside_layer(|s| paint(s, &mut off, &frame));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
let mut pixels = pm.data_as_u8_slice().to_vec();
|
||||
r.finish(&mut pixels);
|
||||
Some((pixels, usize::from(w), usize::from(h)))
|
||||
}
|
||||
|
||||
/// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip).
|
||||
fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) {
|
||||
let mut m = frame.offscreen_context(pm.width(), pm.height());
|
||||
@@ -1564,8 +2044,17 @@ impl Renderer {
|
||||
let outset = n.appearance.outset();
|
||||
let orule = if item.is_fill() { rule } else { FillRule::NonZero };
|
||||
// Each item's shadows get their own cache slots.
|
||||
let content =
|
||||
Content { node: n, owner: None, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) };
|
||||
// Rasterize makes its image over the item's silhouette.
|
||||
let bounds = rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| outline.bounding_box());
|
||||
let content = Content {
|
||||
node: n,
|
||||
owner: None,
|
||||
cache: Some((j + 1) << 8),
|
||||
reach: ib.inflate(outset, outset),
|
||||
outline: Some((&outline, orule)),
|
||||
bounds,
|
||||
kept: None,
|
||||
};
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,19 @@ pub fn effect_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of what `n` draws with its own and its members' geometry effects applied and with the
|
||||
/// reach of its own and its members' raster effects, measured as [`Node::visual_bounds_with`]
|
||||
/// measures art without effects: what Object › Rasterize makes its image over.
|
||||
pub fn drawn_bounds(doc: &Document, n: &Node) -> Option<Rect> {
|
||||
fx::painted_reach(&doc.symbol_boxes(), n)
|
||||
}
|
||||
|
||||
/// The outline, with `n`'s geometry effects applied, that the canvas clips `n`'s Rasterize with
|
||||
/// Create Clipping Mask to. Object › Rasterize's clipping mask follows it.
|
||||
pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
|
||||
fx::drawn_outline(doc, n)
|
||||
}
|
||||
|
||||
/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser
|
||||
/// addresses at most `u16::MAX` pixels per side (and panics at that edge).
|
||||
pub const MAX_RASTER_SIDE: u32 = 32_768;
|
||||
@@ -2301,9 +2314,10 @@ 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) {
|
||||
// The version the type names, when it's installed (see `FontDb::face_version`).
|
||||
// The version the type names, when it's installed (see `FontDb::face_version`), at its
|
||||
// Variable Font settings (see `FontDb::style_face`).
|
||||
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);
|
||||
let face = db.style_face(&r.style).unwrap_or(f);
|
||||
(m == vectorcraft_text::FontMatch::Missing, Some(face.id()))
|
||||
}
|
||||
None => (true, None),
|
||||
@@ -2427,6 +2441,8 @@ mod tests_objectfx;
|
||||
#[cfg(test)]
|
||||
mod tests_placed_document;
|
||||
#[cfg(test)]
|
||||
mod tests_rasterize;
|
||||
#[cfg(test)]
|
||||
mod tests_setup;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegradient;
|
||||
|
||||
@@ -83,6 +83,22 @@ fn type_with_raster_effects_redraws_when_its_font_becomes_available() {
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// Type with Variable Font settings is drawn in a face of their own, which is not a substitute:
|
||||
/// Document Setup's highlight of substituted glyphs leaves it alone, and so does Highlight
|
||||
/// Alternate Glyphs.
|
||||
#[test]
|
||||
fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
|
||||
let _adding = adding_fonts();
|
||||
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let font_axes = [("wght".to_string(), 650.0)].into_iter().collect();
|
||||
let style = CharStyle { size: 40.0, font_family: TWO_AXIS_FAMILY.into(), font_axes, ..Default::default() };
|
||||
let g = text_geom(&TextObject::point(Point::new(10.0, 60.0), "lIn", style));
|
||||
assert!(!g.all.elements().is_empty());
|
||||
assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty());
|
||||
assert!(g.alternate_glyphs.elements().is_empty());
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
|
||||
/// available: the glyph geometry kept for it is laid out again.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
//! Effect › Rasterize on the canvas: the art drawn as square pixels of the effect's resolution
|
||||
//! over its bounds and the room around them, on white or on nothing, its paths left as they are.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Effect, Node};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, SubPath, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A red shape (`path`, over 10..30 pt) with the effects `fx` (id, params), in a document whose
|
||||
/// raster effects resolution is `ppi`.
|
||||
fn doc(path: PathData, fx: &[(&str, Value)], ppi: f64) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.raster_effects_ppi = ppi;
|
||||
let l = d.layers[0].id;
|
||||
let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
|
||||
n.appearance.effects = fx.iter().map(|(id, params)| Effect { id: (*id).into(), params: params.clone(), visible: true }).collect();
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A right triangle over 10..30 pt whose long side runs diagonally.
|
||||
fn triangle() -> PathData {
|
||||
let corner = |x, y| Anchor::corner(Point::new(x, y));
|
||||
PathData::single(SubPath::new(vec![corner(10.0, 10.0), corner(30.0, 10.0), corner(10.0, 30.0)], true))
|
||||
}
|
||||
|
||||
/// `d` drawn at `zoom` device pixels a point on a transparent canvas `size` pixels square.
|
||||
fn draw(d: &Document, zoom: f64, size: u32) -> Rendered {
|
||||
Renderer::new().render(d, size, size, Affine::scale(zoom), &RenderOptions { background: None, artboards: false, ..Default::default() })
|
||||
}
|
||||
|
||||
fn pixel(r: &Rendered, x: usize, y: usize) -> [u8; 4] {
|
||||
let i = (y * r.width as usize + x) * 4;
|
||||
[r.pixels[i], r.pixels[i + 1], r.pixels[i + 2], r.pixels[i + 3]]
|
||||
}
|
||||
|
||||
/// Whether each of the `n` × `n` squares `side` device pixels wide from (`at`, `at`) is one color.
|
||||
fn squares_are_flat(r: &Rendered, at: usize, side: usize, n: usize) -> bool {
|
||||
(0..n * n).all(|k| {
|
||||
let (x0, y0) = (at + (k % n) * side, at + (k / n) * side);
|
||||
let first = pixel(r, x0, y0);
|
||||
(0..side * side).all(|i| pixel(r, x0 + i % side, y0 + i / side) == first)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_draws_the_art_in_pixels_of_its_resolution() {
|
||||
// (params, the document's resolution, device pixels a point, device pixels an image pixel)
|
||||
let cases = [
|
||||
(json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 10.0, 10),
|
||||
(json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 2.0, 2),
|
||||
(json!({"useDocumentResolution": false, "resolution": 300}), 72.0, 25.0, 6),
|
||||
(json!({}), 150.0, 25.0, 12),
|
||||
];
|
||||
for (params, ppi, zoom, side) in cases {
|
||||
let at = (10.0 * zoom) as usize;
|
||||
// The whole image pixels inside the 20 pt triangle's bounds.
|
||||
let n = (20.0 * zoom) as usize / side;
|
||||
let canvas = (40.0 * zoom) as u32;
|
||||
let rasterized = draw(&doc(triangle(), &[("rasterize", params.clone())], ppi), zoom, canvas);
|
||||
assert!(squares_are_flat(&rasterized, at, side, n), "{params}");
|
||||
// Along the diagonal an image pixel takes the mean of the art it covers.
|
||||
let partly = (0..n).map(|k| pixel(&rasterized, at + k * side, at + (n - 1 - k) * side)[3]).filter(|a| *a > 0 && *a < 255).count();
|
||||
assert!(partly > n / 2, "{params}: {partly} of {n}");
|
||||
// Without the effect the diagonal edge runs through those squares.
|
||||
assert!(!squares_are_flat(&draw(&doc(triangle(), &[], ppi), zoom, canvas), at, side, n));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_is_drawn_where_only_its_image_reaches_the_view() {
|
||||
// At 1 ppi a pixel of the image is 72 pt wide: the 20 pt square's image runs to 82 pt.
|
||||
let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 1.0);
|
||||
// A view of 40..80 pt across, where only the image's white reaches.
|
||||
let opts = RenderOptions { background: None, artboards: false, ..Default::default() };
|
||||
let r = Renderer::new().render(&d, 40, 40, Affine::translate((-40.0, 0.0)), &opts);
|
||||
assert_eq!(pixel(&r, 20, 20)[3], 255);
|
||||
// A blur of 4 pt (6 pt of spread) and 10 pt of white around it: the white reaches 46 pt.
|
||||
let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"background": "white", "addAround": 10}))];
|
||||
let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0);
|
||||
let r = Renderer::new().render(&d, 40, 40, Affine::translate((-45.0, 0.0)), &opts);
|
||||
assert_eq!(pixel(&r, 0, 20), [255; 4]);
|
||||
// A fill's own Rasterize with 100 pt of white around: the white reaches 130 pt.
|
||||
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0);
|
||||
let l = d.layers[0].id;
|
||||
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
|
||||
n.appearance.items[0].effects_mut().push(Effect {
|
||||
id: "rasterize".into(),
|
||||
params: json!({"background": "white", "addAround": 100}),
|
||||
visible: true,
|
||||
});
|
||||
let r = Renderer::new().render(&d, 40, 40, Affine::translate((-115.0, 0.0)), &opts);
|
||||
assert_eq!(pixel(&r, 5, 20), [255; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_that_paints_nothing_takes_no_room_in_the_image() {
|
||||
// A second fill, hidden, under a Gaussian Blur that would spread 15 pt.
|
||||
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0);
|
||||
let l = d.layers[0].id;
|
||||
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
|
||||
let mut hidden = n.appearance.items[0].clone();
|
||||
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden {
|
||||
f.visible = false;
|
||||
f.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 10}), visible: true });
|
||||
}
|
||||
n.appearance.items.push(hidden.clone());
|
||||
// A third, hidden too, offset 5 pt outward.
|
||||
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden {
|
||||
f.effects = vec![Effect { id: "path.offsetPath".into(), params: json!({"offset": 5}), visible: true }];
|
||||
}
|
||||
n.appearance.items.push(hidden);
|
||||
let r = draw(&d, 10.0, 400);
|
||||
assert_eq!((pixel(&r, 105, 105), pixel(&r, 95, 200)[3]), ([255, 0, 0, 255], 0));
|
||||
// Nor does a hidden member of a group.
|
||||
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0);
|
||||
let mut away = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), Appearance::default());
|
||||
away.visible = false;
|
||||
let l = d.layers[0].id;
|
||||
let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
|
||||
members.push(Arc::new(away));
|
||||
let mut group = Node::group(d.alloc_id(), members);
|
||||
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true });
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0);
|
||||
// Nor does a Shaper group's source art, its first member, which paints nothing.
|
||||
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0);
|
||||
let none = Appearance::basic(Paint::None, Paint::None, 0.0);
|
||||
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), none);
|
||||
let l = d.layers[0].id;
|
||||
let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
|
||||
members.insert(0, Arc::new(source));
|
||||
let mut group = Node::group(d.alloc_id(), members);
|
||||
group.extras_mut().shaper = Some(Box::new(vectorcraft_doc::shaper::ShaperSpec { faces: vec![], edges: vec![] }));
|
||||
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true });
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0);
|
||||
// Nor does the path where its only fill's own Transform effect moves the fill away from it.
|
||||
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0);
|
||||
let l = d.layers[0].id;
|
||||
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
|
||||
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
|
||||
f.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 40}), visible: true });
|
||||
}
|
||||
let r = draw(&d, 10.0, 800);
|
||||
assert_eq!((pixel(&r, 600, 200), pixel(&r, 200, 200)[3]), ([255, 0, 0, 255], 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_covers_the_share_of_a_screen_pixel_its_image_covers() {
|
||||
// A 1 pt square from x = 10.25 at 72 ppi and 100%: its image pixel covers a quarter of
|
||||
// screen column 11, on white and on nothing.
|
||||
let square = || shapes::rectangle(Rect::new(10.25, 10.0, 11.25, 11.0));
|
||||
for (params, want) in [(json!({}), [64, 0, 0, 64]), (json!({"background": "white"}), [64, 0, 0, 64])] {
|
||||
let r = draw(&doc(square(), &[("rasterize", params.clone())], 72.0), 1.0, 20);
|
||||
assert_eq!(pixel(&r, 11, 10), want, "{params}");
|
||||
assert_eq!(pixel(&r, 12, 10)[3], 0, "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_wider_than_a_raster_can_be_are_drawn_with_their_pixel_effects() {
|
||||
// 65,530 pt across at 100%: wider than the rasters the effects are made in can safely be.
|
||||
let bar = || shapes::rectangle(Rect::new(0.0, 10.0, 65_530.0, 11.0));
|
||||
let opts = RenderOptions { background: None, artboards: false, ..Default::default() };
|
||||
for fx in [("rasterize", json!({"background": "white"})), ("blur.gaussian", json!({"radius": 1}))] {
|
||||
let r = Renderer::new().render(&doc(bar(), std::slice::from_ref(&fx), 72.0), 40, 20, Affine::translate((-65_500.0, 0.0)), &opts);
|
||||
assert!(pixel(&r, 10, 10)[3] > 0, "{}", fx.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_of_ten_thousand_rasterize_effects_is_drawn() {
|
||||
let fx = vec![("rasterize", json!({"background": "white"})); 10_000];
|
||||
let r = draw(&doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0), 1.0, 40);
|
||||
assert_eq!(pixel(&r, 20, 20), [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_of_rasterize_effects_with_shadows_draws_the_content_of_each_once() {
|
||||
let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"}));
|
||||
let glow = ("stylize.outerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#00ff00"}));
|
||||
// Five Rasterize effects, each followed by a drop shadow and an outer glow: on a 20 pt square
|
||||
// at 72 ppi, and on a 300 pt square at 2,400 ppi, whose images are too large for the canvas to
|
||||
// draw on their own grid.
|
||||
for (ppi, side, zoom) in [(72.0, 20.0, 1.0), (2400.0, 300.0, 0.1)] {
|
||||
let rasterize = ("rasterize", json!({"useDocumentResolution": false, "resolution": ppi}));
|
||||
let fx: Vec<_> = (0..5).flat_map(|_| [rasterize.clone(), shadow.clone(), glow.clone()]).collect();
|
||||
let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 10.0 + side, 10.0 + side)), &fx, 72.0);
|
||||
let drawn = || crate::fx::RASTERIZED.with(std::cell::Cell::get);
|
||||
let before = drawn();
|
||||
let r = draw(&d, zoom, 40);
|
||||
assert_eq!(drawn() - before, 5, "{ppi} ppi");
|
||||
assert_eq!(pixel(&r, 15, 15), [255, 0, 0, 255], "{ppi} ppi");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_resolution_redraws_a_rasterized_member_of_a_blurred_group() {
|
||||
let mut d = doc(triangle(), &[("rasterize", json!({}))], 72.0);
|
||||
let l = d.layers[0].id;
|
||||
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
|
||||
let mut group = Node::group(d.alloc_id(), members);
|
||||
group.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 0.5}), visible: true });
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let opts = RenderOptions { background: None, artboards: false, ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.render(&d, 400, 400, Affine::scale(10.0), &opts);
|
||||
d.raster_effects_ppi = 18.0;
|
||||
let again = r.render(&d, 400, 400, Affine::scale(10.0), &opts);
|
||||
assert_eq!(again.pixels, Renderer::new().render(&d, 400, 400, Affine::scale(10.0), &opts).pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_draws_the_image_with_the_effects_own_anti_aliasing() {
|
||||
// A supersampled Art Optimized export draws its art with hard edges and then averages them;
|
||||
// the effect's image keeps its smooth diagonal.
|
||||
let d = doc(triangle(), &[("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))], 72.0);
|
||||
let opts = RenderOptions { background: None, artboards: false, anti_alias: AntiAlias::Art, supersample: true, ..Default::default() };
|
||||
let r = Renderer::new().render_region_with(&d, Rect::new(0.0, 0.0, 40.0, 40.0), 10.0, &opts);
|
||||
let partly = (0..20).map(|k| pixel(&r, 105 + k * 10, 105 + (19 - k) * 10)[3]).filter(|a| *a > 0 && *a < 255).count();
|
||||
assert!(partly > 10, "{partly} of 20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_without_anti_aliasing_has_hard_edges_at_any_zoom() {
|
||||
let fx = [("rasterize", json!({"useDocumentResolution": false, "resolution": 72, "antiAlias": "none"}))];
|
||||
for zoom in [10.0, 1.0] {
|
||||
let r = draw(&doc(triangle(), &fx, 72.0), zoom, (40.0 * zoom) as u32);
|
||||
let alphas: Vec<u8> = r.pixels.chunks(4).map(|p| p[3]).collect();
|
||||
assert!(alphas.iter().all(|a| *a == 0 || *a == 255), "zoom {zoom}");
|
||||
assert!(alphas.contains(&255));
|
||||
}
|
||||
// A bar 0.6 pt wide covers the center of the image pixel it starts, which it then fills, and
|
||||
// which at 50% covers half a screen pixel: it shows there, partly covering it.
|
||||
let bar = || shapes::rectangle(Rect::new(10.3, 10.0, 10.9, 30.0));
|
||||
assert_eq!(pixel(&draw(&doc(bar(), &fx, 72.0), 10.0, 400), 105, 200)[3], 255);
|
||||
let half = pixel(&draw(&doc(bar(), &fx, 72.0), 0.5, 20), 5, 10)[3];
|
||||
assert!(half > 0 && half < 255, "{half}");
|
||||
// At 100% that image pixel, 10.3..11.3 pt, covers 70% of screen column 10 and 30% of column 11.
|
||||
let full = draw(&doc(bar(), &fx, 72.0), 1.0, 40);
|
||||
assert_eq!((pixel(&full, 10, 20)[3], pixel(&full, 11, 20)[3]), (178, 77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_keeps_what_an_earlier_effect_spreads_beyond_the_bounds() {
|
||||
let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))];
|
||||
let r = draw(&doc(triangle(), &fx, 72.0), 10.0, 400);
|
||||
// 1.5 pt left of the triangle's bounds, the blur still shows.
|
||||
assert!(pixel(&r, 85, 150)[3] > 0);
|
||||
// So does a blur of the fill's own.
|
||||
let mut d = doc(triangle(), &fx[1..], 72.0);
|
||||
let l = d.layers[0].id;
|
||||
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
|
||||
n.appearance.items[0].effects_mut().push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 4}), visible: true });
|
||||
assert!(pixel(&draw(&d, 10.0, 400), 85, 150)[3] > 0);
|
||||
// A fill's own Add Around that stays inside a 40 pt stroke takes no room past the stroke.
|
||||
let mut d = doc(shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)), &[("rasterize", json!({"background": "white"}))], 72.0);
|
||||
let l = d.layers[0].id;
|
||||
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
|
||||
n.appearance.items[0].effects_mut().push(Effect { id: "rasterize".into(), params: json!({"addAround": 10}), visible: true });
|
||||
n.appearance.items[1] = vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 40.0));
|
||||
let r = draw(&d, 2.0, 200);
|
||||
assert_eq!((pixel(&r, 156, 100)[3], pixel(&r, 170, 100)[3]), (255, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_and_shadows_paint_in_stack_order() {
|
||||
let square = || shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0));
|
||||
let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 5, "y": 5, "blur": 0, "color": "#0000ff"}));
|
||||
let gray = ("rasterize", json!({"colorModel": "grayscale"}));
|
||||
// Below the square's corner, only the shadow paints.
|
||||
let first = draw(&doc(square(), &[shadow.clone(), gray.clone()], 72.0), 10.0, 400);
|
||||
let [r, g, b, a] = pixel(&first, 330, 330);
|
||||
assert!(a == 255 && r == g && g == b, "the shadow is rasterized in grayscale: {:?}", [r, g, b, a]);
|
||||
let after = draw(&doc(square(), &[gray.clone(), shadow.clone()], 72.0), 10.0, 400);
|
||||
assert_eq!(pixel(&after, 330, 330), [0, 0, 255, 255], "a shadow after Rasterize keeps its color");
|
||||
// A shadow after Rasterize is the image's: a circle's square image on white casts a square shadow.
|
||||
let circle = shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0));
|
||||
let cast = draw(&doc(circle, &[("rasterize", json!({"background": "white"})), shadow.clone()], 72.0), 10.0, 400);
|
||||
assert_eq!(pixel(&cast, 320, 160), [0, 0, 255, 255]);
|
||||
// Between two Rasterize effects, the second one takes it in.
|
||||
let between = draw(&doc(square(), &[("rasterize", json!({})), shadow.clone(), gray], 72.0), 10.0, 400);
|
||||
let [r, g, b, _] = pixel(&between, 330, 330);
|
||||
assert!(r == g && g == b && b > 0, "{:?}", [r, g, b]);
|
||||
// A member's shadow is part of a group's art.
|
||||
let mut d = doc(square(), std::slice::from_ref(&shadow), 72.0);
|
||||
let l = d.layers[0].id;
|
||||
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
|
||||
let mut group = Node::group(d.alloc_id(), members);
|
||||
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]);
|
||||
// So are a member's Crop Marks: the top right mark runs from x 39 to 57 at y 10.
|
||||
let mut d = doc(square(), &[("cropMarks", json!({}))], 72.0);
|
||||
let l = d.layers[0].id;
|
||||
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
|
||||
let mut group = Node::group(d.alloc_id(), members);
|
||||
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let r = draw(&d, 10.0, 400);
|
||||
assert!((95..105).any(|y| pixel(&r, 395, y)[3] > 0));
|
||||
// So is the shadow in a symbol's art, for its instance.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut art = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
|
||||
);
|
||||
art.appearance.effects.push(Effect { id: shadow.0.into(), params: shadow.1.clone(), visible: true });
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Shadowed".into(), art: Arc::new(art) });
|
||||
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shadowed".into(), xf: Affine::translate((20.0, 20.0)) });
|
||||
instance.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, instance).unwrap();
|
||||
assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_takes_in_the_art_of_a_3d_effect() {
|
||||
let square = || shapes::rectangle(Rect::new(30.0, 30.0, 50.0, 50.0));
|
||||
let extrude = ("threeD.extrude", json!({}));
|
||||
let solid = draw(&doc(square(), std::slice::from_ref(&extrude), 72.0), 10.0, 800);
|
||||
// Pixels the extruded art covers outside the square.
|
||||
let outside: Vec<(usize, usize)> = (0..800 * 800)
|
||||
.map(|i| (i % 800, i / 800))
|
||||
.filter(|(x, y)| !((300..500).contains(x) && (300..500).contains(y)) && pixel(&solid, *x, *y)[3] == 255)
|
||||
.collect();
|
||||
assert!(!outside.is_empty());
|
||||
let rasterized = draw(&doc(square(), &[extrude, ("rasterize", json!({}))], 72.0), 10.0, 800);
|
||||
assert!(outside.iter().all(|(x, y)| pixel(&rasterized, *x, *y)[3] > 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_takes_in_brush_art_wider_than_the_stroke() {
|
||||
// A 1 pt line under the 10 pt. Oval brush, which paints about 2 pt from its center line.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
ap.stroke_mut().unwrap().brush = Some("10 pt. Oval".into());
|
||||
let mut n = Node::path(d.alloc_id(), shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), ap);
|
||||
n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let r = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() });
|
||||
assert!(pixel(&r, 100, 52)[3] > 128, "{:?}", pixel(&r, 100, 52));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_takes_in_type_that_its_own_fills_effects_move() {
|
||||
// "IIII" is set at 40 pt from x 5 pt with a red fill of its own under the characters, and a
|
||||
// Transform effect on that fill moves it 100 pt to the right.
|
||||
let typed = |fx: &[Effect]| {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let style = vectorcraft_doc::text::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
let text = vectorcraft_doc::text::TextObject::point(Point::new(5.0, 60.0), "IIII", style);
|
||||
let mut n = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Text(Box::new(text)));
|
||||
let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
fill.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 100}), visible: true });
|
||||
n.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(fill));
|
||||
n.appearance.set_contents_at(1);
|
||||
n.appearance.effects = fx.to_vec();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() })
|
||||
};
|
||||
// The pixels right of x 100 that the moved fill paints.
|
||||
let moved = |r: &Rendered| (0..200 * 100).filter(|i| i % 200 >= 100 && pixel(r, i % 200, i / 200)[0] > 128).count();
|
||||
assert!(moved(&typed(&[])) > 50);
|
||||
let rasterize = Effect { id: "rasterize".into(), params: json!({"useDocumentResolution": false, "resolution": 72}), visible: true };
|
||||
assert!(moved(&typed(&[rasterize])) > 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_fills_its_area_on_white_and_add_around_grows_it() {
|
||||
let circle = || shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0));
|
||||
let at = |params: Value| draw(&doc(circle(), &[("rasterize", params)], 72.0), 10.0, 400);
|
||||
let white = at(json!({"background": "white"}));
|
||||
// Inside the bounds, outside the circle: white; outside the bounds: nothing.
|
||||
assert_eq!((pixel(&white, 105, 105), pixel(&white, 200, 200)), ([255; 4], [255, 0, 0, 255]));
|
||||
assert_eq!(pixel(&white, 95, 200)[3], 0);
|
||||
let clear = at(json!({"background": "transparent"}));
|
||||
assert_eq!((pixel(&clear, 105, 105)[3], pixel(&clear, 200, 200)), (0, [255, 0, 0, 255]));
|
||||
// Add Around Object: 2 pt more on every side.
|
||||
let around = at(json!({"background": "white", "addAround": 2}));
|
||||
assert_eq!((pixel(&around, 85, 200), pixel(&around, 75, 200)[3]), ([255; 4], 0));
|
||||
// A second Rasterize makes its image over the first one's whole pixels, no larger.
|
||||
let twice = draw(&doc(circle(), &[("rasterize", json!({})), ("rasterize", json!({"background": "white"}))], 72.0), 10.0, 400);
|
||||
assert_eq!((pixel(&twice, 105, 105), pixel(&twice, 95, 200)[3], pixel(&twice, 305, 200)[3]), ([255; 4], 0, 0));
|
||||
// A Feather or an Inner Glow after a Rasterize on white works on the image's square edge.
|
||||
let white = ("rasterize", json!({"background": "white"}));
|
||||
let feathered = draw(&doc(circle(), &[white.clone(), ("stylize.feather", json!({"radius": 1}))], 72.0), 10.0, 400);
|
||||
assert!(pixel(&feathered, 106, 106)[3] > 0);
|
||||
let glow = ("stylize.innerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#ff0000", "source": "edge"}));
|
||||
let glowing = draw(&doc(circle(), &[white.clone(), glow.clone()], 72.0), 10.0, 400);
|
||||
assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102));
|
||||
// With a Rasterize on nothing between them, the glow follows the same edge.
|
||||
let glowing = draw(&doc(circle(), &[white, ("rasterize", json!({})), glow], 72.0), 10.0, 400);
|
||||
assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102));
|
||||
// Create Clipping Mask: the white stays inside the circle.
|
||||
let clipped = at(json!({"background": "white", "clippingMask": true}));
|
||||
assert_eq!((pixel(&clipped, 105, 105)[3], pixel(&clipped, 200, 200)), (0, [255, 0, 0, 255]));
|
||||
// Inside the outline the white shows where the art paints nothing: a circle stroked only.
|
||||
let mut ring = doc(circle(), &[], 72.0);
|
||||
let l = ring.layers[0].id;
|
||||
let n = Arc::make_mut(&mut ring.children_mut(Some(l)).unwrap()[0]);
|
||||
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into();
|
||||
n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white", "clippingMask": true}), visible: true });
|
||||
let ring = draw(&ring, 10.0, 400);
|
||||
assert_eq!((pixel(&ring, 200, 200), pixel(&ring, 105, 105)[3]), ([255; 4], 0));
|
||||
// A stroke 1 pt wide widens the image by half a point a side, as it widens the visual bounds.
|
||||
let mut stroked = doc(circle(), &[], 72.0);
|
||||
let n = Arc::make_mut(&mut stroked.children_mut(Some(l)).unwrap()[0]);
|
||||
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into();
|
||||
n.appearance.effects.push(Effect {
|
||||
id: "rasterize".into(),
|
||||
params: json!({"background": "white", "resolution": 720, "useDocumentResolution": false}),
|
||||
visible: true,
|
||||
});
|
||||
let stroked = draw(&stroked, 10.0, 400);
|
||||
assert_eq!((pixel(&stroked, 96, 105), pixel(&stroked, 93, 105)[3]), ([255; 4], 0));
|
||||
}
|
||||
+24
-5
@@ -70,15 +70,26 @@ pub(crate) fn transparency(n: &Node) -> Props {
|
||||
}
|
||||
|
||||
/// The weight and italic of the face style `st` asks for: what embedded fonts' `@font-face`
|
||||
/// rules and the characters using them say, so each face is told apart (Semibold from Bold).
|
||||
/// rules and the characters using them say, so each face is told apart (Semibold from Bold). A
|
||||
/// Variable Font `wght` setting is the weight.
|
||||
pub(crate) fn font_descriptor(st: &CharStyle) -> (u16, bool) {
|
||||
let fs = st.font_style.to_ascii_lowercase();
|
||||
(vectorcraft_text::style_weight(&st.font_style).round().clamp(1.0, 1000.0) as u16, fs.contains("italic") || fs.contains("oblique"))
|
||||
let weight =
|
||||
st.font_axes.get("wght").copied().filter(|w| w.is_finite()).unwrap_or_else(|| f64::from(vectorcraft_text::style_weight(&st.font_style)));
|
||||
(weight.round().clamp(1.0, 1000.0) as u16, fs.contains("italic") || fs.contains("oblique"))
|
||||
}
|
||||
|
||||
/// The width character style `st` asks for, in percent of the normal width: its Variable Font
|
||||
/// `wdth` setting (an axis in those units), which `font-stretch` writes; `None` without one.
|
||||
pub(crate) fn font_stretch(st: &CharStyle) -> Option<f64> {
|
||||
st.font_axes.get("wdth").copied().filter(|w| w.is_finite() && *w > 0.0)
|
||||
}
|
||||
|
||||
/// The font of character style `st`: family, size (the vertical scale; writers stretch the
|
||||
/// horizontal one), weight and style, lengths written by `len`. The weight is the style's number
|
||||
/// (600 for Semibold, 300 for Light), `bold` for 700 and left out for 400.
|
||||
/// horizontal one), weight and style, and its Variable Font settings, lengths written by `len`.
|
||||
/// The weight is the style's number (600 for Semibold, 300 for Light) or the `wght` setting,
|
||||
/// `bold` for 700 and left out for 400. The settings are `font-variation-settings` (`"wght" 650`),
|
||||
/// and the `wdth` setting is `font-stretch` too ([`font_stretch`]).
|
||||
pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
let mut p = Props::new();
|
||||
// A quoted family escapes `\` and `'`, which would otherwise end the string early.
|
||||
@@ -98,6 +109,14 @@ pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
if italic {
|
||||
p.push(("font-style", "italic".into()));
|
||||
}
|
||||
if let Some(w) = font_stretch(st) {
|
||||
p.push(("font-stretch", format!("{}%", fmt_num(w, 3))));
|
||||
}
|
||||
let axes: Vec<String> =
|
||||
st.font_axes.iter().filter(|(_, v)| v.is_finite()).map(|(tag, v)| format!("{} {}", css_string(tag), fmt_num(*v, 3))).collect();
|
||||
if !axes.is_empty() {
|
||||
p.push(("font-variation-settings", axes.join(", ")));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
@@ -596,7 +615,7 @@ impl Rules<'_> {
|
||||
}
|
||||
RasterFx::GaussianBlur { radius } => filter = Some(format!("blur({})", self.len(radius / 2.0))),
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => art.unsupported("inner glows or feathering"),
|
||||
RasterFx::Pixel(_) => art.unsupported("pixel effects"),
|
||||
RasterFx::Pixel(_) | RasterFx::Rasterize(_) => art.unsupported("pixel effects"),
|
||||
}
|
||||
}
|
||||
if !shadows.is_empty() {
|
||||
|
||||
+40
-12
@@ -329,11 +329,13 @@ struct Writer<'a> {
|
||||
}
|
||||
|
||||
/// The characters type uses from one face, under one `@font-face` description: the family the
|
||||
/// type names and the weight and italic its style asks for.
|
||||
/// type names and the weight, italic and width its style asks for.
|
||||
struct FontUse {
|
||||
family: String,
|
||||
weight: u16,
|
||||
italic: bool,
|
||||
/// The `font-stretch` percentage ([`css::font_stretch`]), as bits; `None` for the normal width.
|
||||
stretch: Option<u64>,
|
||||
face: Arc<FontFace>,
|
||||
chars: BTreeSet<char>,
|
||||
}
|
||||
@@ -689,12 +691,13 @@ impl Writer<'_> {
|
||||
match styling {
|
||||
Styling::PresentationAttributes => {
|
||||
// CSS-only properties go in a style attribute (SVG Tiny has none: left out).
|
||||
let (style, attrs): (Vec<_>, Vec<_>) =
|
||||
props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-kerning"));
|
||||
let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| {
|
||||
matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-variation-settings" | "font-kerning")
|
||||
});
|
||||
let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect();
|
||||
if !style.is_empty() {
|
||||
if self.tiny() {
|
||||
self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation and font features are left out");
|
||||
self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation, font features and font variation settings are left out");
|
||||
} else {
|
||||
s.push_str(&format!(" style=\"{}\"", xml_escape(&css::declarations(style))));
|
||||
}
|
||||
@@ -1652,7 +1655,7 @@ impl Writer<'_> {
|
||||
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let mut opened = 0;
|
||||
for f in fx.iter().rev() {
|
||||
if matches!(f, RasterFx::Pixel(_)) {
|
||||
if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) {
|
||||
self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out");
|
||||
continue;
|
||||
}
|
||||
@@ -1713,7 +1716,7 @@ impl Writer<'_> {
|
||||
sd(*radius)
|
||||
),
|
||||
RasterFx::GaussianBlur { radius } => format!("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/>", sd(*radius)),
|
||||
RasterFx::Pixel(_) => continue,
|
||||
RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue,
|
||||
};
|
||||
// Filters composite normally: a shadow's or glow's other blend mode is recorded for import.
|
||||
let mode = match f {
|
||||
@@ -2043,10 +2046,14 @@ impl Writer<'_> {
|
||||
}
|
||||
let Some(f) = &face else { continue };
|
||||
let (weight, italic) = font_descriptor(st);
|
||||
let at = self.fonts.iter().position(|u| u.face.id() == f.id() && (u.weight, u.italic) == (weight, italic) && u.family == st.font_family);
|
||||
let stretch = css::font_stretch(st).map(f64::to_bits);
|
||||
let at = self
|
||||
.fonts
|
||||
.iter()
|
||||
.position(|u| u.face.id() == f.id() && (u.weight, u.italic, u.stretch) == (weight, italic, stretch) && u.family == st.font_family);
|
||||
let at = at.unwrap_or_else(|| {
|
||||
let family = st.font_family.clone();
|
||||
self.fonts.push(FontUse { family, weight, italic, face: f.clone(), chars: BTreeSet::new() });
|
||||
self.fonts.push(FontUse { family, weight, italic, stretch, face: f.clone(), chars: BTreeSet::new() });
|
||||
self.fonts.len() - 1
|
||||
});
|
||||
let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue };
|
||||
@@ -2065,10 +2072,30 @@ impl Writer<'_> {
|
||||
/// The `@font-face` rules of the faces type uses ([`ExportOptions::embed_fonts`]): each face
|
||||
/// subset to its characters (whole when its licence forbids subsetting, left out with a
|
||||
/// warning when it forbids embedding). Faces sharing a description cover their own
|
||||
/// characters (`unicode-range`).
|
||||
/// characters (`unicode-range`); where two of them share characters as well, a warning says
|
||||
/// that browsers draw those in one of the faces.
|
||||
fn font_faces(&mut self) -> Vec<String> {
|
||||
let uses = std::mem::take(&mut self.fonts);
|
||||
let mut rules = Vec::new();
|
||||
fn described(u: &FontUse) -> (&str, u16, bool, Option<u64>) {
|
||||
(&u.family, u.weight, u.italic, u.stretch)
|
||||
}
|
||||
let mut clashing: Vec<&String> = vec![];
|
||||
for (i, u) in uses.iter().enumerate() {
|
||||
let clash = uses
|
||||
.get(..i)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|o| described(o) == described(u) && o.face.id() != u.face.id() && !o.chars.is_disjoint(&u.chars));
|
||||
if clash && !clashing.contains(&&u.family) {
|
||||
clashing.push(&u.family);
|
||||
}
|
||||
}
|
||||
for family in clashing {
|
||||
self.warn(&format!(
|
||||
"type in {family} uses faces of one weight, style and width that share characters: browsers draw those characters in one of the embedded faces"
|
||||
));
|
||||
}
|
||||
for u in &uses {
|
||||
let chars: Vec<char> = u.chars.iter().copied().collect();
|
||||
let name = format!("{} {}", u.face.family, u.face.style);
|
||||
@@ -2079,11 +2106,12 @@ impl Writer<'_> {
|
||||
if !font.subset {
|
||||
self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting"));
|
||||
}
|
||||
let shared = uses.iter().filter(|o| (&o.family, o.weight, o.italic) == (&u.family, u.weight, u.italic)).count() > 1;
|
||||
let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1;
|
||||
let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() };
|
||||
let stretch = u.stretch.map(|w| format!(";font-stretch:{}%", fmt_num(f64::from_bits(w), 3))).unwrap_or_default();
|
||||
let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") };
|
||||
rules.push(format!(
|
||||
"@font-face{{font-family:{};font-weight:{};font-style:{};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}",
|
||||
"@font-face{{font-family:{};font-weight:{};font-style:{}{stretch};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}",
|
||||
css_string(&u.family),
|
||||
u.weight,
|
||||
if u.italic { "italic" } else { "normal" },
|
||||
@@ -2142,7 +2170,7 @@ fn run_diff(base: &Props, run: Props) -> Props {
|
||||
/// on a `<tspan>`).
|
||||
fn initial_value(k: &str) -> Option<&'static str> {
|
||||
Some(match k {
|
||||
"font-weight" | "font-style" | "font-feature-settings" => "normal",
|
||||
"font-weight" | "font-style" | "font-stretch" | "font-feature-settings" | "font-variation-settings" => "normal",
|
||||
"stroke" | "stroke-dasharray" => "none",
|
||||
"stroke-width" | "stroke-opacity" => "1",
|
||||
"stroke-linecap" => "butt",
|
||||
|
||||
@@ -60,6 +60,27 @@ fn style_name(weight: u16, italic: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The axis settings of a `font-variation-settings` value (`"wght" 650, "wdth" 87.5`), by tag:
|
||||
/// none for `normal` and `initial`. `None` when an item isn't a quoted tag of four printable ASCII
|
||||
/// characters and a finite number, as CSS then ignores the declaration.
|
||||
fn variation_settings(v: &str) -> Option<std::collections::BTreeMap<String, f64>> {
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
if ["normal", "initial"].iter().any(|k| v.trim().eq_ignore_ascii_case(k)) {
|
||||
return Some(out);
|
||||
}
|
||||
for item in v.split(',') {
|
||||
let item = item.trim();
|
||||
let quote = item.chars().next().filter(|c| *c == '"' || *c == '\'')?;
|
||||
let (tag, value) = item.get(1..)?.split_once(quote)?;
|
||||
let value = value.trim().parse::<f64>().ok().filter(|v| v.is_finite())?;
|
||||
if tag.len() != 4 || !tag.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
|
||||
return None;
|
||||
}
|
||||
out.insert(tag.to_string(), value);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn solid(c: svgtypes::Color) -> Paint {
|
||||
Paint::solid(Color::rgb8(c.red, c.green, c.blue))
|
||||
}
|
||||
@@ -377,6 +398,10 @@ impl Ctx {
|
||||
st.size = size;
|
||||
let italic = css.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique");
|
||||
st.font_style = style_name(self.weight(n), italic);
|
||||
// Inherited: `inherit`, `unset`, `revert` and a value CSS ignores leave the one inherited.
|
||||
let inherits = |v: &String| ["inherit", "unset", "revert", "revert-layer"].iter().any(|k| v.trim().eq_ignore_ascii_case(k));
|
||||
let own = |a: XNode| css.own(a, "font-variation-settings").filter(|v| !inherits(v)).and_then(|v| variation_settings(&v));
|
||||
st.font_axes = n.ancestors().filter(|a| a.is_element()).find_map(own).unwrap_or_default();
|
||||
let (fill, mut fill_url) = self.paint(n, "fill");
|
||||
st.fill = fill;
|
||||
let (stroke, mut stroke_url) = self.paint(n, "stroke");
|
||||
|
||||
@@ -241,6 +241,49 @@ fn variable_font_instances_embed_as_themselves() {
|
||||
assert!(advances[1] > advances[0], "{advances:?}");
|
||||
}
|
||||
|
||||
/// Variable Font settings are written as `font-variation-settings`, with the `wght` setting as the
|
||||
/// weight and the `wdth` setting as the width; the font embeds as a static font at the settings,
|
||||
/// and the settings read back. Faces that one weight, style and width describe and that share
|
||||
/// characters are warned about.
|
||||
#[test]
|
||||
fn variable_font_settings_are_written_embedded_and_read_back() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font};
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
db.add_font(two_axis_font().unwrap());
|
||||
let set = [("wdth".to_string(), 90.0), ("wght".to_string(), 650.0)].into_iter().collect::<std::collections::BTreeMap<_, _>>();
|
||||
let varied = CharStyle { font_axes: set.clone(), ..style(FAMILY, "Regular") };
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
add(&mut d, text(Point::new(20.0, 60.0), &[("ll", style(FAMILY, "Regular")), ("ll", varied.clone())]));
|
||||
let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None);
|
||||
assert!(o.svg.contains("font-variation-settings:"wdth" 90, "wght" 650"), "{}", o.svg);
|
||||
assert!(o.svg.contains("font-weight=\"650\"") && o.svg.contains("font-stretch=\"90%\""), "{}", o.svg);
|
||||
let fonts = embedded(&o.svg);
|
||||
assert_eq!(fonts.iter().map(|f| f.1).collect::<Vec<_>>(), [400, 650], "{:?}", o.warnings);
|
||||
assert!(o.svg.contains("font-style:normal;font-stretch:90%;src:"), "{}", o.svg);
|
||||
assert!(o.warnings.is_empty(), "{:?}", o.warnings);
|
||||
// The Condensed instance and Regular: one description, the same characters.
|
||||
let mut same = Document::new(300.0, 200.0);
|
||||
add(&mut same, text(Point::new(20.0, 60.0), &[("ll", style(FAMILY, "Regular")), ("ll", style(FAMILY, "Condensed"))]));
|
||||
let warnings = export_full(&same, &ExportOptions { embed_fonts: true, ..Default::default() }, None).warnings;
|
||||
assert!(warnings.iter().any(|w| w.contains(FAMILY) && w.contains("share characters")), "{warnings:?}");
|
||||
let face = db.style_face(&varied).unwrap();
|
||||
let f = skrifa::FontRef::new(&fonts[1].2).unwrap();
|
||||
assert!(f.fvar().is_err() && f.gvar().is_err(), "a static font");
|
||||
let adv = f.hmtx().unwrap().advance(f.cmap().unwrap().map_codepoint('l').unwrap()).unwrap();
|
||||
assert_eq!(f64::from(adv), face.advance(face.glyph_for('l')).round());
|
||||
let regular = db.face(FAMILY, "Regular").unwrap();
|
||||
assert!(f64::from(adv) > regular.advance(regular.glyph_for('l')) + 5.0, "the instance's own glyphs");
|
||||
// Read back: the settings, on the run that has them.
|
||||
let back = import(&o.svg).unwrap();
|
||||
let mut axes = vec![];
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
axes.extend(t.runs.iter().map(|r| r.style.font_axes.clone()));
|
||||
}
|
||||
});
|
||||
assert_eq!(axes, [Default::default(), set]);
|
||||
}
|
||||
|
||||
/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original
|
||||
/// name) with OS/2 `fsType` set to `fs_type`.
|
||||
fn licensed_font(family: &str, fs_type: u16) -> Vec<u8> {
|
||||
|
||||
@@ -232,6 +232,25 @@ fn weights_spacing_and_decoration() {
|
||||
assert_eq!(style('a').kerning, None);
|
||||
}
|
||||
|
||||
/// `font-variation-settings` becomes the characters' Variable Font settings, inherited as CSS
|
||||
/// inherits it; `normal` and `initial` set none, and `unset` and a value CSS ignores leave the
|
||||
/// inherited settings.
|
||||
#[test]
|
||||
fn font_variation_settings_become_variable_font_settings() {
|
||||
let d = open(
|
||||
r#"<text y="20" style="font-variation-settings: 'wght' 650, "wdth" 87.5">a<tspan>b</tspan><tspan style="font-variation-settings: normal">c</tspan><tspan style="font-variation-settings: 'weight' 650">d</tspan><tspan style="font-variation-settings: 'wght' heavy">e</tspan><tspan style="font-variation-settings: initial">f</tspan><tspan style="font-variation-settings: unset">g</tspan></text>"#,
|
||||
);
|
||||
let t = only_text(&d);
|
||||
let axes = |c: char| t.runs.iter().find(|r| r.text.contains(c)).unwrap().style.font_axes.clone().into_iter().collect::<Vec<_>>();
|
||||
let set = vec![("wdth".to_string(), 87.5), ("wght".to_string(), 650.0)];
|
||||
for c in ['a', 'b', 'd', 'e', 'g'] {
|
||||
assert_eq!(axes(c), set, "{c}");
|
||||
}
|
||||
for c in ['c', 'f'] {
|
||||
assert!(axes(c).is_empty(), "{c}: {:?}", axes(c));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_selectors_reach_text() {
|
||||
let d = open(
|
||||
|
||||
+157
-2
@@ -42,6 +42,41 @@ pub type Variation = ([u8; 4], f32);
|
||||
const MAX_AXES: usize = 64;
|
||||
const MAX_INSTANCES: usize = 512;
|
||||
|
||||
/// One axis of a variable font's design space, as its `fvar` table lists it: the tag (`wght`), its
|
||||
/// name in the font's name table (`Weight`; the tag when it has none) and its range and default,
|
||||
/// in the axis's own units.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VariationAxis {
|
||||
pub tag: [u8; 4],
|
||||
pub name: String,
|
||||
pub min: f32,
|
||||
pub default: f32,
|
||||
pub max: f32,
|
||||
/// The font asks for the axis not to be shown to users (the `fvar` hidden flag).
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// Faces of variable fonts instanced at axis settings their named instances don't have
|
||||
/// ([`FontDb::instance`]). Each setting of a face keeps its id for the life of the process, so a
|
||||
/// laid-out glyph's face can be found again ([`FontDb::face_by_id`]) after the built face was
|
||||
/// dropped to bound the memory the cache holds.
|
||||
#[derive(Default)]
|
||||
struct Instances {
|
||||
/// The id of each face and setting.
|
||||
ids: HashMap<InstanceKey, u32>,
|
||||
/// What each id is: the face it was instanced from and its axis settings.
|
||||
recipes: HashMap<u32, (u32, Vec<Variation>)>,
|
||||
/// The instanced faces built, by id; dropped all at once when [`MAX_LIVE_INSTANCES`] are held.
|
||||
live: HashMap<u32, Arc<FontFace>>,
|
||||
}
|
||||
|
||||
/// A face instanced at axis settings: the id of the face, and the settings by tag, as bits.
|
||||
type InstanceKey = (u32, Vec<([u8; 4], u32)>);
|
||||
|
||||
/// How many instanced faces [`Instances::live`] holds at most.
|
||||
pub(crate) const MAX_LIVE_INSTANCES: usize = 256;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FontBytes {
|
||||
Static(&'static [u8]),
|
||||
Owned(Arc<Vec<u8>>),
|
||||
@@ -144,6 +179,27 @@ impl FontFace {
|
||||
pub fn variations(&self) -> &[Variation] {
|
||||
&self.variations
|
||||
}
|
||||
/// The variation axes of the face's font (its `fvar` table, at most 64); none for a static
|
||||
/// face.
|
||||
pub fn axes(&self) -> Vec<VariationAxis> {
|
||||
let Some(f) = self.skrifa() else { return vec![] };
|
||||
f.axes()
|
||||
.iter()
|
||||
.take(MAX_AXES)
|
||||
.map(|a| {
|
||||
let tag = a.tag().to_be_bytes();
|
||||
let fallback = || String::from_utf8_lossy(&tag).trim().to_string();
|
||||
let name = name(&f, &[a.name_id()]).unwrap_or_else(fallback);
|
||||
VariationAxis { tag, name, min: a.min_value(), default: a.default_value(), max: a.max_value(), hidden: a.is_hidden() }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The face's setting of axis `tag`: its named instance's ([`Self::variations`]), else the
|
||||
/// axis default. `None` when the font has no such axis.
|
||||
pub fn axis_value(&self, tag: [u8; 4]) -> Option<f32> {
|
||||
let axis = self.axes().into_iter().find(|a| a.tag == tag)?;
|
||||
Some(self.variations.iter().find(|(t, _)| *t == tag).map_or(axis.default, |(_, v)| *v))
|
||||
}
|
||||
/// Unique id of this face within the process.
|
||||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
@@ -382,6 +438,8 @@ pub struct FontDb {
|
||||
/// What the last scan read, to tell when fonts were installed or removed since.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
stamp: Mutex<Option<ScanStamp>>,
|
||||
/// Faces instanced at the axis settings of type's Variable Font sliders ([`FontDb::instance`]).
|
||||
instances: Mutex<Instances>,
|
||||
}
|
||||
|
||||
/// Where a database's system font scan looks: folders (read with their subfolders) and font files.
|
||||
@@ -1378,6 +1436,25 @@ pub(crate) fn fonts_outside(registered: impl IntoIterator<Item = String>, font_d
|
||||
files
|
||||
}
|
||||
|
||||
/// `face` at the axis settings `variations` (user units), as face `id`: its names, kind, italic
|
||||
/// and file, and its weight the `wght` setting when there is one.
|
||||
fn instanced(face: &FontFace, variations: Vec<Variation>, id: u32) -> Option<FontFace> {
|
||||
let weight = variations.iter().find(|(t, _)| t == b"wght").map_or(face.weight, |(_, v)| *v);
|
||||
let spec = FaceStyle {
|
||||
family: face.family.clone(),
|
||||
native_family: face.native_family.clone(),
|
||||
style: face.style.clone(),
|
||||
keys: face.keys.clone(),
|
||||
variations,
|
||||
weight: Some(weight),
|
||||
italic: face.italic,
|
||||
traits: face.traits,
|
||||
};
|
||||
let mut f = make_face(face.bytes.clone(), face.index, spec, face.path.clone())?;
|
||||
f.id = id;
|
||||
Some(f)
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::path::PathBuf>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
@@ -1525,6 +1602,7 @@ impl FontDb {
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
stamp: Mutex::new(None),
|
||||
instances: Mutex::new(Instances::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2015,9 +2093,86 @@ impl FontDb {
|
||||
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|cf| norm(&cf.style) == ns))
|
||||
}
|
||||
|
||||
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from).
|
||||
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from), an
|
||||
/// instanced face ([`Self::instance`]) included.
|
||||
pub fn face_by_id(&self, id: u32) -> Option<Arc<FontFace>> {
|
||||
self.read_faces().iter().find(|f| f.id == id).cloned()
|
||||
let loaded = self.read_faces().iter().find(|f| f.id == id).cloned();
|
||||
loaded.or_else(|| self.instance_by_id(id))
|
||||
}
|
||||
|
||||
/// The face of character style `st`: [`Self::face_version`] of its font, instanced at its
|
||||
/// Variable Font settings ([`Self::instance`]).
|
||||
pub fn style_face(&self, st: &vectorcraft_doc::CharStyle) -> Option<Arc<FontFace>> {
|
||||
let face = self.face_version(&st.font_family, &st.font_style, st.font_version.as_deref())?;
|
||||
Some(self.instance(&face, &st.font_axes))
|
||||
}
|
||||
|
||||
/// `face` with the axis settings `axes` (by tag, in the axes' own units) in place of its own,
|
||||
/// each clamped to its axis's range: its outlines, metrics and advances taken there, and its
|
||||
/// file embedded instanced there. Tags the font has no axis for are left out. `face` itself
|
||||
/// when that changes no setting, and for a static face.
|
||||
pub fn instance(&self, face: &Arc<FontFace>, axes: &std::collections::BTreeMap<String, f64>) -> Arc<FontFace> {
|
||||
if axes.is_empty() {
|
||||
return face.clone();
|
||||
}
|
||||
let font_axes = face.axes();
|
||||
let mut variations = face.variations.clone();
|
||||
for (tag, value) in axes {
|
||||
let Some(axis) = font_axes.iter().find(|a| a.tag.as_slice() == tag.as_bytes()) else { continue };
|
||||
if !value.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let v = (*value as f32).clamp(axis.min.min(axis.max), axis.max.max(axis.min));
|
||||
match variations.iter_mut().find(|(t, _)| *t == axis.tag) {
|
||||
Some(setting) => setting.1 = v,
|
||||
None => variations.push((axis.tag, v)),
|
||||
}
|
||||
}
|
||||
// Every axis at the setting it has in `face`: nothing to instance.
|
||||
let at = |vs: &[Variation], a: &VariationAxis| vs.iter().find(|(t, _)| *t == a.tag).map_or(a.default, |(_, v)| *v);
|
||||
if font_axes.iter().all(|a| at(&variations, a) == at(&face.variations, a)) {
|
||||
return face.clone();
|
||||
}
|
||||
variations.sort_by_key(|(t, _)| *t);
|
||||
let key = (face.id, variations.iter().map(|(t, v)| (*t, v.to_bits())).collect::<Vec<_>>());
|
||||
let mut inst = self.instances.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let id = match inst.ids.get(&key) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
inst.ids.insert(key, id);
|
||||
inst.recipes.insert(id, (face.id, variations.clone()));
|
||||
id
|
||||
}
|
||||
};
|
||||
if let Some(f) = inst.live.get(&id) {
|
||||
return f.clone();
|
||||
}
|
||||
let Some(built) = instanced(face, variations, id) else { return face.clone() };
|
||||
let built = Arc::new(built);
|
||||
if inst.live.len() >= MAX_LIVE_INSTANCES {
|
||||
inst.live.clear();
|
||||
}
|
||||
inst.live.insert(id, built.clone());
|
||||
built
|
||||
}
|
||||
|
||||
/// The instanced face with id `id` ([`Self::instance`]), built again when it was dropped.
|
||||
fn instance_by_id(&self, id: u32) -> Option<Arc<FontFace>> {
|
||||
let (base, variations) = {
|
||||
let inst = self.instances.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(f) = inst.live.get(&id) {
|
||||
return Some(f.clone());
|
||||
}
|
||||
inst.recipes.get(&id).cloned()?
|
||||
};
|
||||
let base = self.read_faces().iter().find(|f| f.id == base).cloned()?;
|
||||
let built = Arc::new(instanced(&base, variations, id)?);
|
||||
let mut inst = self.instances.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if inst.live.len() >= MAX_LIVE_INSTANCES {
|
||||
inst.live.clear();
|
||||
}
|
||||
Some(inst.live.entry(id).or_insert(built).clone())
|
||||
}
|
||||
|
||||
/// The available family `name` names, ignoring case and anything but letters and digits, as
|
||||
|
||||
@@ -347,8 +347,7 @@ 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_version(&first.font_family, &first.font_style, first.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
let centre = db.style_face(first).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN
|
||||
}
|
||||
_ => QUARTER_TURN,
|
||||
@@ -1729,8 +1728,7 @@ 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_version(&st.font_family, &st.font_style, st.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
let centre = cx.db.style_face(st).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 {
|
||||
|
||||
@@ -525,6 +525,8 @@ pub fn logical_order(visual: &str) -> Option<(Vec<usize>, bool)> {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_axes;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_combos;
|
||||
|
||||
@@ -281,7 +281,7 @@ 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_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
|
||||
let Some(face) = db.style_face(st) else {
|
||||
return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading());
|
||||
};
|
||||
let k = st.size / face.upem;
|
||||
@@ -291,7 +291,7 @@ 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_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
|
||||
let Some(face) = db.style_face(st) else {
|
||||
return (st.size * 0.7 * vs, st.size * 0.5 * vs);
|
||||
};
|
||||
let k = st.size / face.upem * vs;
|
||||
@@ -357,12 +357,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_version(&st.font_family, &st.font_style, st.font_version.as_deref()).or_else(|| db.face_covering('a')) {
|
||||
if let Some(face) = db.style_face(st).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_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { continue };
|
||||
let Some(primary) = db.style_face(st) 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;
|
||||
@@ -438,6 +438,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
font_family,
|
||||
font_style,
|
||||
font_version,
|
||||
font_axes,
|
||||
size,
|
||||
leading: _,
|
||||
tracking,
|
||||
@@ -470,6 +471,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
*font_family == b.font_family
|
||||
&& *font_style == b.font_style
|
||||
&& *font_version == b.font_version
|
||||
&& *font_axes == b.font_axes
|
||||
&& *size == b.size
|
||||
&& *h_scale == b.h_scale
|
||||
&& *v_scale == b.v_scale
|
||||
|
||||
@@ -91,6 +91,85 @@ pub fn variable_font() -> Option<Vec<u8>> {
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`two_axis_font`].
|
||||
pub const TWO_AXIS_FAMILY: &str = "Biaxtest Sans";
|
||||
|
||||
/// How much narrower [`two_axis_font`] makes [`VARIABLE_CHARS`] at its narrowest width.
|
||||
pub const TWO_AXIS_NARROW: f32 = 0.2;
|
||||
|
||||
/// The bundled Source Sans 3 Regular as a variable font of family [`TWO_AXIS_FAMILY`] with two axes:
|
||||
/// weight (`wght` 400–700, default 400), whose maximum makes the glyphs of [`VARIABLE_CHARS`]
|
||||
/// [`VARIABLE_WIDEN`] wider, and width (`wdth` 75–100, default 100), whose minimum makes them
|
||||
/// [`TWO_AXIS_NARROW`] narrower (outlines and advances, through `gvar`). Its named instances are
|
||||
/// Regular (the default), Bold (`wght` 700) and Condensed (`wdth` 75). `None` when it can't be
|
||||
/// built.
|
||||
pub fn two_axis_font() -> Option<Vec<u8>> {
|
||||
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let font = skrifa::FontRef::new(BASE).ok()?;
|
||||
let (glyf, loca) = (font.glyf().ok()?, font.loca(None).ok()?);
|
||||
let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default());
|
||||
let charmap = font.charmap();
|
||||
let varied: Vec<GlyphId> = VARIABLE_CHARS.chars().filter_map(|c| charmap.map(c)).collect();
|
||||
let mut variations = Vec::new();
|
||||
for g in 0..u32::from(font.maxp().ok()?.num_glyphs()) {
|
||||
let id = GlyphId::new(g);
|
||||
let mut deltas = vec![];
|
||||
if varied.contains(&id) {
|
||||
let Ok(Some(Glyph::Simple(s))) = loca.get_glyf(id, &glyf) else { return None };
|
||||
let advance = metrics.advance_width(id)?;
|
||||
// At each axis's far end: every point and the advance phantom point moved by `k` of their x.
|
||||
let moved = |k: f32| {
|
||||
let by = |x: f32| (x * k).round() as i16;
|
||||
let mut d: Vec<GlyphDelta> = s.points().map(|p| GlyphDelta::required(by(f32::from(p.x)), 0)).collect();
|
||||
d.extend([(0, 0), (by(advance), 0), (0, 0), (0, 0)].map(|(x, y)| GlyphDelta::required(x, y)));
|
||||
d
|
||||
};
|
||||
let peak = |w: f32, d: f32| vec![Tent::new(F2Dot14::from_f32(w), None), Tent::new(F2Dot14::from_f32(d), None)];
|
||||
deltas.push(GlyphDeltas::new(peak(1.0, 0.0), moved(VARIABLE_WIDEN)));
|
||||
deltas.push(GlyphDeltas::new(peak(0.0, -1.0), moved(-TWO_AXIS_NARROW)));
|
||||
}
|
||||
variations.push(GlyphVariations::new(write_fonts::types::GlyphId::new(g), deltas));
|
||||
}
|
||||
let gvar = Gvar::new(variations, 2).ok()?;
|
||||
let axis = |tag: &[u8; 4], min: i32, default: i32, max: i32, name: u16| VariationAxisRecord {
|
||||
axis_tag: Tag::new(tag),
|
||||
min_value: Fixed::from_i32(min),
|
||||
default_value: Fixed::from_i32(default),
|
||||
max_value: Fixed::from_i32(max),
|
||||
flags: 0,
|
||||
axis_name_id: NameId::new(name),
|
||||
};
|
||||
let instance = |name: u16, wght: i32, wdth: i32| InstanceRecord {
|
||||
subfamily_name_id: NameId::new(name),
|
||||
flags: 0,
|
||||
coordinates: vec![Fixed::from_i32(wght), Fixed::from_i32(wdth)],
|
||||
post_script_name_id: None,
|
||||
};
|
||||
let axes = vec![axis(b"wght", 400, 400, 700, 256), axis(b"wdth", 75, 100, 100, 257)];
|
||||
let fvar = Fvar::new(AxisInstanceArrays::new(axes, vec![instance(258, 400, 100), instance(259, 700, 100), instance(260, 400, 75)]));
|
||||
let name = name_table(&[
|
||||
(1, TWO_AXIS_FAMILY),
|
||||
(2, "Regular"),
|
||||
(4, "Biaxtest Sans Regular"),
|
||||
(6, "BiaxtestSans-Regular"),
|
||||
(256, "Weight"),
|
||||
(257, "Width"),
|
||||
(258, "Regular"),
|
||||
(259, "Bold"),
|
||||
(260, "Condensed"),
|
||||
])?;
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
builder.add_table(&fvar).ok()?.add_table(&gvar).ok()?;
|
||||
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())
|
||||
}
|
||||
|
||||
/// A `name` table of Windows English (US) records (name id, string).
|
||||
pub fn name_table(records: &[(u16, &str)]) -> Option<Vec<u8>> {
|
||||
let mut records = records.to_vec();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Variable Font settings: type set at axis settings no named instance has is shaped, drawn,
|
||||
//! measured and embedded there.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use kurbo::{Point, Shape};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::raw::TableProvider;
|
||||
use vectorcraft_doc::CharStyle;
|
||||
|
||||
use super::test_fonts::{TWO_AXIS_FAMILY as FAMILY, TWO_AXIS_NARROW, VARIABLE_CHARS, VARIABLE_WIDEN, two_axis_font};
|
||||
use super::*;
|
||||
|
||||
/// A database of the bundled fonts and the two-axis test font.
|
||||
fn db() -> FontDb {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(two_axis_font().expect("the test font builds")), 3, "Regular, Bold and Condensed");
|
||||
db
|
||||
}
|
||||
|
||||
fn axes(settings: &[(&str, f64)]) -> BTreeMap<String, f64> {
|
||||
settings.iter().map(|(t, v)| (t.to_string(), *v)).collect()
|
||||
}
|
||||
|
||||
fn text(style: &str, settings: &[(&str, f64)]) -> TextObject {
|
||||
let st = CharStyle { font_family: FAMILY.into(), font_style: style.into(), size: 100.0, font_axes: axes(settings), ..CharStyle::default() };
|
||||
TextObject::point(Point::ZERO, VARIABLE_CHARS, st)
|
||||
}
|
||||
|
||||
/// The advances and the ink width of the laid-out text.
|
||||
fn widths(db: &FontDb, t: &TextObject) -> (f64, f64) {
|
||||
let l = layout(db, t);
|
||||
(l.glyphs.iter().map(|g| g.advance).sum(), l.to_bezpath().bounding_box().width())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_font_lists_its_axes() {
|
||||
let db = db();
|
||||
let regular = db.face(FAMILY, "Regular").unwrap();
|
||||
let list: Vec<(String, f32, f32, f32)> = regular.axes().into_iter().map(|a| (a.name, a.min, a.default, a.max)).collect();
|
||||
assert_eq!(list, [("Weight".to_string(), 400.0, 400.0, 700.0), ("Width".to_string(), 75.0, 100.0, 100.0)]);
|
||||
assert_eq!(regular.axes()[0].tag, *b"wght");
|
||||
// A named instance's settings, and the defaults of the axes it leaves alone.
|
||||
let bold = db.face(FAMILY, "Bold").unwrap();
|
||||
assert_eq!((bold.axis_value(*b"wght"), bold.axis_value(*b"wdth"), bold.axis_value(*b"opsz")), (Some(700.0), Some(100.0), None));
|
||||
assert!(db.face(FALLBACK_FAMILY, "Regular").unwrap().axes().is_empty(), "a static face");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_settings_lay_out_and_draw_between_the_named_instances() {
|
||||
let db = db();
|
||||
let (regular, bold) = (widths(&db, &text("Regular", &[])), widths(&db, &text("Bold", &[])));
|
||||
let between = widths(&db, &text("Regular", &[("wght", 650.0)]));
|
||||
// wght 650 is 5/6 of the way to the heaviest weight: the glyphs are 5/6 of the way wider.
|
||||
let k = 1.0 + f64::from(VARIABLE_WIDEN) * 250.0 / 300.0;
|
||||
assert!((between.0 - regular.0 * k).abs() < 1.0, "advances {regular:?} {between:?} {bold:?}");
|
||||
assert!((between.1 - regular.1 * k).abs() < 1.5, "ink {regular:?} {between:?} {bold:?}");
|
||||
assert!(regular.0 < between.0 && between.0 < bold.0 && regular.1 < between.1 && between.1 < bold.1);
|
||||
// The glyphs come from a face of their own, found again by its id, at those settings.
|
||||
let l = layout(&db, &text("Regular", &[("wght", 650.0)]));
|
||||
let face = db.face_by_id(l.glyphs[0].font_id).unwrap();
|
||||
assert_eq!((face.family.as_str(), face.style.as_str(), face.variations(), face.weight), (FAMILY, "Regular", &[(*b"wght", 650.0)][..], 650.0));
|
||||
assert_ne!(face.id(), db.face(FAMILY, "Regular").unwrap().id());
|
||||
// The same settings again: the same face.
|
||||
assert_eq!(layout(&db, &text("Regular", &[("wght", 650.0)])).glyphs[0].font_id, face.id());
|
||||
// Width on top of the Bold instance: Bold's weight stays, the glyphs narrow.
|
||||
let narrow = widths(&db, &text("Bold", &[("wdth", 75.0)]));
|
||||
let k = (1.0 + f64::from(VARIABLE_WIDEN)) - f64::from(TWO_AXIS_NARROW);
|
||||
assert!((narrow.0 - regular.0 * k).abs() < 1.0, "{narrow:?} vs {regular:?}");
|
||||
let l = layout(&db, &text("Bold", &[("wdth", 75.0)]));
|
||||
assert_eq!(db.face_by_id(l.glyphs[0].font_id).unwrap().variations(), [(*b"wdth", 75.0), (*b"wght", 700.0)]);
|
||||
}
|
||||
|
||||
/// Neighboring runs at other settings are shaped apart, each in its own instance; runs at the same
|
||||
/// settings that differ only in paint are shaped together and keep the font's kerning.
|
||||
#[test]
|
||||
fn other_settings_shape_a_run_apart_and_the_same_settings_keep_the_kerning() {
|
||||
let db = db();
|
||||
// 40 pt "AVAV", which the font kerns, at `settings`, with the bytes `restyled` restyled by
|
||||
// `restyle`: each glyph's source byte, pen position, advance and face id.
|
||||
let avav = |settings: &[(&str, f64)], restyled: std::ops::Range<usize>, restyle: &dyn Fn(&mut CharStyle)| {
|
||||
let st = CharStyle { font_family: FAMILY.into(), size: 40.0, font_axes: axes(settings), ..CharStyle::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, "AVAV", st);
|
||||
edit::style_range(&mut t.runs, restyled.start, restyled.end, restyle);
|
||||
layout(&db, &t).glyphs.iter().map(|g| (g.byte, g.origin.x, g.advance, g.font_id)).collect::<Vec<_>>()
|
||||
};
|
||||
let same = [("wght", 650.0)];
|
||||
let plain = avav(&same, 0..0, &|_| {});
|
||||
let unkerned = avav(&same, 0..4, &|s| s.kerning = Some(0.0));
|
||||
assert!((plain[0].2 - unkerned[0].2).abs() > 0.1, "the font kerns A and V: {plain:?} {unkerned:?}");
|
||||
assert_eq!(avav(&same, 1..2, &|s| s.fill = Default::default()), plain, "a V in another paint at the same settings");
|
||||
// The V at another weight: an instance of its own, shaped without the A-V pair.
|
||||
let apart = avav(&same, 1..2, &|s| {
|
||||
s.font_axes = axes(&[("wght", 500.0)]);
|
||||
});
|
||||
let face = db.face_by_id(apart[1].3).unwrap();
|
||||
assert_eq!(face.variations(), [(*b"wght", 500.0)], "{apart:?}");
|
||||
assert_ne!(apart[1].3, plain[1].3);
|
||||
assert_eq!((apart[0].2, apart[0].3), (unkerned[0].2, plain[0].3), "the A before it is not kerned with it: {apart:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_are_clamped_and_unknown_axes_left_out() {
|
||||
let db = db();
|
||||
let regular = db.face(FAMILY, "Regular").unwrap();
|
||||
let past = db.instance(®ular, &axes(&[("wght", 2000.0), ("opsz", 12.0)]));
|
||||
assert_eq!(past.variations(), [(*b"wght", 700.0)]);
|
||||
// Drawn as the heaviest weight.
|
||||
let bold = db.face(FAMILY, "Bold").unwrap();
|
||||
let l = bold.glyph_for('l');
|
||||
assert_eq!(past.advance(l), bold.advance(l));
|
||||
assert_eq!(db.outline(&past, l).to_svg(), db.outline(&bold, l).to_svg());
|
||||
// Settings that change nothing, and a static face: the face itself.
|
||||
for same in [axes(&[("wght", 400.0)]), axes(&[("wdth", 100.0), ("opsz", 12.0)]), axes(&[("wght", f64::NAN)])] {
|
||||
assert_eq!(db.instance(®ular, &same).id(), regular.id(), "{same:?}");
|
||||
}
|
||||
assert_eq!(db.instance(&bold, &axes(&[("wght", 700.0)])).id(), bold.id());
|
||||
let fixed = db.face(FALLBACK_FAMILY, "Regular").unwrap();
|
||||
assert_eq!(db.instance(&fixed, &axes(&[("wght", 650.0)])).id(), fixed.id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_is_found_by_its_id_after_the_cache_drops_it() {
|
||||
let db = db();
|
||||
let regular = db.face(FAMILY, "Regular").unwrap();
|
||||
let first = db.instance(®ular, &axes(&[("wght", 401.0)]));
|
||||
let (id, l) = (first.id(), first.glyph_for('l'));
|
||||
let (advance, outline) = (first.advance(l), db.outline(&first, l).to_svg());
|
||||
// More settings than the cache holds.
|
||||
for i in 0..fontdb::MAX_LIVE_INSTANCES + 10 {
|
||||
db.instance(®ular, &axes(&[("wght", 402.0 + i as f64 * 0.5)]));
|
||||
}
|
||||
drop(first);
|
||||
// A glyph laid out before keeps its face: built again, with the same id and settings.
|
||||
let again = db.face_by_id(id).unwrap();
|
||||
assert_eq!((again.id(), again.variations(), again.advance(l)), (id, &[(*b"wght", 401.0)][..], advance));
|
||||
assert_eq!(db.outline(&again, l).to_svg(), outline);
|
||||
assert_eq!(db.instance(®ular, &axes(&[("wght", 401.0)])).id(), id, "the same settings, the same id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_embeds_as_a_static_font_at_its_settings() {
|
||||
let db = db();
|
||||
let regular = db.face(FAMILY, "Regular").unwrap();
|
||||
let face = db.instance(®ular, &axes(&[("wght", 650.0), ("wdth", 90.0)]));
|
||||
let e = face.embed(&['l']).unwrap();
|
||||
let f = skrifa::FontRef::new(&e.data).unwrap();
|
||||
assert!(f.fvar().is_err() && f.gvar().is_err(), "no variations left");
|
||||
let adv = f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(f.charmap().map('l').unwrap()).unwrap();
|
||||
assert!((f64::from(adv) - face.advance(face.glyph_for('l'))).abs() <= 1.0, "{adv}");
|
||||
assert!((f64::from(adv) - regular.advance(regular.glyph_for('l'))).abs() > 5.0, "not the default instance's: {adv}");
|
||||
}
|
||||
@@ -126,6 +126,15 @@ shown_when = ["The Selection tool over the type widget of selected type (a doubl
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a type badge (a letter T)."
|
||||
|
||||
[[cursor]]
|
||||
id = "LoadedText"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"The Selection tool after a click on the red out port of selected area type whose text overflows: the pointer carries that text until a click threads it into a closed path, a click on empty canvas makes a new frame of the same shape there, or a drag draws the new frame. Escape or another tool drops it.",
|
||||
]
|
||||
hotspot = "Where the new frame's top-left corner goes (the click), or the path clicked."
|
||||
look = "A small block of text lines: the overflow text the pointer carries. Until it is drawn it borrows the I-beam (Text)."
|
||||
|
||||
# ── Pen family ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
|
||||
@@ -518,6 +518,9 @@ pub enum Cursor {
|
||||
/// Over the type widget of selected type (a double-click converts point type to area type and
|
||||
/// back): the arrow with a type badge.
|
||||
TypeWidget,
|
||||
/// The Selection tool loaded with the text of an area type's out port (a click threads it
|
||||
/// into a path or a new frame, a drag draws the new frame).
|
||||
LoadedText,
|
||||
/// The Shape Builder: a crosshair with a plus (merge mode)...
|
||||
ShapeBuilder,
|
||||
/// ...or, with Alt held, a minus (erase mode).
|
||||
@@ -667,7 +670,7 @@ impl Cursor {
|
||||
}
|
||||
|
||||
/// The cursor this one borrows until it's drawn: the generic crosshair for the
|
||||
/// tools, the I-beam for vertical type, the Pen's add cursor for the Mesh tool and Puppet
|
||||
/// tools, the I-beam for vertical type and loaded text, the Pen's add cursor for the Mesh tool and Puppet
|
||||
/// Warp, not-allowed for the Scissors away from a path. `None` for a cursor with a look of
|
||||
/// its own.
|
||||
pub fn stand_in(self) -> Option<Self> {
|
||||
@@ -695,7 +698,7 @@ impl Cursor {
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors => Some(Cursor::Crosshair),
|
||||
Cursor::TypeVertical => Some(Cursor::Text),
|
||||
Cursor::TypeVertical | Cursor::LoadedText => Some(Cursor::Text),
|
||||
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
|
||||
Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed),
|
||||
_ => None,
|
||||
|
||||
+146
-1
@@ -16,6 +16,10 @@
|
||||
//! scaling its type: the text reflows at its size.
|
||||
//! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and
|
||||
//! E pressed again makes it take the objects it touches.
|
||||
//! A click on the red out port of selected area type whose text overflows loads the pointer with
|
||||
//! that text ([`Cursor::LoadedText`]): the next click threads it into the closed path or area type
|
||||
//! under the pointer, or into a new frame of the same shape and size at the click on empty canvas,
|
||||
//! and a drag draws a rectangle for the new frame (`text.thread.add`). Escape drops the loaded text.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{hit_test, marquee, objects_at};
|
||||
@@ -73,6 +77,12 @@ enum State {
|
||||
Pie(PieDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
/// Loaded with the text of area type `from` by a click on its out port; `press`: where the
|
||||
/// next press went down and where the pointer is now.
|
||||
Loaded {
|
||||
from: NodeId,
|
||||
press: Option<(Point, Point)>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -257,6 +267,45 @@ impl SelectionTool {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The pointer loaded with the text of area type `from` ([`State::Loaded`]): a press starts
|
||||
/// placing it, and its release threads it (`text.thread.add`). None when `from` is no longer
|
||||
/// area type: the press is an ordinary one.
|
||||
fn loaded(&mut self, cx: &ToolContext, ev: &PointerEvent, from: NodeId, press: Option<(Point, Point)>) -> Option<Vec<Action>> {
|
||||
let p = ev.pos;
|
||||
match (ev.kind, press) {
|
||||
(PointerKind::Down, _) if !cx.doc.node(from).is_some_and(is_area_type) => {
|
||||
self.state = State::Idle;
|
||||
return None;
|
||||
}
|
||||
(PointerKind::Down, _) => self.state = State::Loaded { from, press: Some((p, p)) },
|
||||
(PointerKind::Drag, Some((start, _))) => self.state = State::Loaded { from, press: Some((start, p)) },
|
||||
(PointerKind::Up, Some((start, _))) => {
|
||||
self.state = State::Idle;
|
||||
return Some(vec![Action::Exec("text.thread.add".into(), thread_params(cx, from, start, p))]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the text of `from` goes for a press at `start` released at `end`: a dragged rectangle,
|
||||
/// else the closed path or area type pressed on, else a new frame with its top-left at `start`.
|
||||
fn thread_params(cx: &ToolContext, from: NodeId, start: Point, end: Point) -> Value {
|
||||
let r = Rect::from_points(start, end);
|
||||
if r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0) {
|
||||
return json!({ "from": from.0, "rect": [r.x0, r.y0, r.width(), r.height()] });
|
||||
}
|
||||
let frame = |n: &vectorcraft_doc::Node| {
|
||||
is_area_type(n) || matches!(&n.kind, vectorcraft_doc::NodeKind::Path { path, .. } if path.subpaths.first().is_some_and(|s| s.closed))
|
||||
};
|
||||
let to =
|
||||
hit_test(cx.doc, start, cx.hit_options()).map(|h| h.top_object(cx.isolation)).filter(|id| *id != from && cx.doc.node(*id).is_some_and(frame));
|
||||
match to {
|
||||
Some(to) => json!({ "from": from.0, "to": to.0 }),
|
||||
None => json!({ "from": from.0, "at": [start.x, start.y] }),
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for SelectionTool {
|
||||
@@ -277,11 +326,19 @@ impl Tool for SelectionTool {
|
||||
*enclosed = !*enclosed;
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape && matches!(self.state, State::Loaded { .. }) {
|
||||
self.state = State::Idle;
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape { self.guide.cancel() } else { vec![] }
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
key == ToolKey::Letter('e') && matches!(self.state, State::Marquee { .. })
|
||||
match self.state {
|
||||
State::Marquee { .. } => key == ToolKey::Letter('e'),
|
||||
State::Loaded { .. } => key == ToolKey::Escape,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn transforming(&self) -> bool {
|
||||
@@ -294,6 +351,11 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let State::Loaded { from, press } = self.state
|
||||
&& let Some(out) = self.loaded(cx, ev, from, press)
|
||||
{
|
||||
return out;
|
||||
}
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
}
|
||||
@@ -337,6 +399,11 @@ impl Tool for SelectionTool {
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Down, _) => {
|
||||
// 0. The out port of selected area type: the pointer takes its text.
|
||||
if let Some(from) = crate::text::out_port_at(cx, p) {
|
||||
self.state = State::Loaded { from, press: None };
|
||||
return vec![];
|
||||
}
|
||||
// 1. Live Corners widgets, pie widgets and type on a path's brackets, then the
|
||||
// bounding-box handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, false) {
|
||||
@@ -542,6 +609,7 @@ impl Tool for SelectionTool {
|
||||
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
State::Corner(c) => o.extend(c.overlays(cx)),
|
||||
State::Pie(d) => o.extend(d.overlays()),
|
||||
State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
_ => {}
|
||||
}
|
||||
o.extend(self.guides.iter().cloned());
|
||||
@@ -569,6 +637,7 @@ impl Tool for SelectionTool {
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
State::Pie(_) => return Cursor::ArrowHollow,
|
||||
State::Bracket(_) => return Cursor::PathBracket,
|
||||
State::Loaded { .. } => return Cursor::LoadedText,
|
||||
_ => {}
|
||||
}
|
||||
if self.guide.busy() {
|
||||
@@ -1537,4 +1606,80 @@ mod tests {
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]);
|
||||
}
|
||||
|
||||
/// [`doc_with_area_type`] with more text than its frame holds, selected; the rectangle's id and
|
||||
/// where the frame's out port is.
|
||||
fn overflowing_area_type() -> (vectorcraft_doc::Document, NodeId, NodeId, Point) {
|
||||
let (mut d, id) = doc_with_area_type();
|
||||
let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id;
|
||||
let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") };
|
||||
let style = vectorcraft_doc::CharStyle { size: 20.0, ..Default::default() };
|
||||
t.runs = vec![vectorcraft_doc::TextRun::new("Words that fill more than the frame holds. ".repeat(20), style)];
|
||||
// On the frame's right side, above its bottom-right corner (120 × 40 at 100%).
|
||||
let port = t.xf * Point::new(120.0, 40.0 - crate::text::PORT_RISE_PX);
|
||||
(d, id, rect, port)
|
||||
}
|
||||
|
||||
/// #2572: a click on the out port loads the pointer with the text; a click on empty canvas then
|
||||
/// threads it into a new frame there, a click on a closed path into the path, a drag into the
|
||||
/// rectangle it draws.
|
||||
#[test]
|
||||
fn a_click_on_the_out_port_loads_the_text_and_the_next_click_or_drag_threads_it() {
|
||||
let (d, id, rect, port) = overflowing_area_type();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
let thread = |params: Value| vec![Action::Exec("text.thread.add".into(), params)];
|
||||
let load = |t: &mut SelectionTool| {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)).is_empty());
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)).is_empty());
|
||||
assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
|
||||
};
|
||||
load(&mut t);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)).is_empty());
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), thread(json!({"from": id.0, "at": [30.0, 420.0]})));
|
||||
assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "the text is placed");
|
||||
load(&mut t);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)), thread(json!({"from": id.0, "to": rect.0})));
|
||||
load(&mut t);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0));
|
||||
assert!(t.overlays(&cx).contains(&Overlay::Marquee(Rect::new(20.0, 20.0, 80.0, 90.0))), "the new frame's outline");
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)), thread(json!({"from": id.0, "rect": [20.0, 20.0, 60.0, 70.0]})));
|
||||
// The bounding box's corner handle below the port still resizes the frame.
|
||||
let corner = Point::new(port.x, port.y + crate::text::PORT_RISE_PX);
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]);
|
||||
}
|
||||
|
||||
/// Escape drops the loaded text, and the next click is an ordinary one; type that fits its
|
||||
/// frame shows no out port to click.
|
||||
#[test]
|
||||
fn escape_drops_the_loaded_text_and_type_that_fits_has_no_port() {
|
||||
let (d, id, _, port) = overflowing_area_type();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y));
|
||||
t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y));
|
||||
assert!(t.claims_key(&cx, ToolKey::Escape), "Escape is the loaded pointer's");
|
||||
assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty());
|
||||
assert!(!t.claims_key(&cx, ToolKey::Escape));
|
||||
assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), vec![Action::Exec("select.none".into(), json!({}))]);
|
||||
|
||||
let (d, id) = doc_with_area_type();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let cx = crate::testutil::cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y));
|
||||
t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y));
|
||||
assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
|
||||
}
|
||||
}
|
||||
@@ -311,16 +311,32 @@ impl TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit): at the
|
||||
/// bottom right of the frame, at the end of the path for type on a path.
|
||||
fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
|
||||
let port = match &t.kind {
|
||||
TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)),
|
||||
/// How far (screen px) the out port of area type sits above the bottom-right corner of its frame,
|
||||
/// on its right side: clear of the bounding box's corner handle, so both can be grabbed.
|
||||
pub const PORT_RISE_PX: f64 = 12.0;
|
||||
/// Half the out port's side (screen px).
|
||||
const PORT_HALF_PX: f64 = 4.5;
|
||||
|
||||
/// Centre of the out port of `t`: on the right side of an area type frame just above its
|
||||
/// bottom-right corner ([`PORT_RISE_PX`]), at the end of the path for type on a path.
|
||||
fn out_port(t: &TextObject, cx: &ToolContext) -> Option<Point> {
|
||||
match &t.kind {
|
||||
TextKind::Area { frame } => {
|
||||
let b = frame.bounds()?;
|
||||
let corner = t.xf * Point::new(b.x1, b.y1);
|
||||
let up = t.xf * Point::new(b.x1, b.y0) - corner;
|
||||
let len = up.hypot();
|
||||
Some(if len > 1e-9 && len.is_finite() { corner + up * (cx.tol(PORT_RISE_PX).min(len / 2.0) / len) } else { corner })
|
||||
}
|
||||
TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)),
|
||||
TextKind::Point => None,
|
||||
};
|
||||
let Some(c) = port else { return };
|
||||
let h = cx.tol(4.5);
|
||||
}
|
||||
}
|
||||
|
||||
/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit), at [`out_port`].
|
||||
fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
|
||||
let Some(c) = out_port(t, cx) else { return };
|
||||
let h = cx.tol(PORT_HALF_PX);
|
||||
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
|
||||
let red = [230, 30, 30];
|
||||
o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false });
|
||||
@@ -329,6 +345,20 @@ fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
|
||||
o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false });
|
||||
}
|
||||
|
||||
/// The selected area type whose red out port is under `p` (only text that overflows shows one):
|
||||
/// a click there loads the Selection tool with its text, to thread into another frame.
|
||||
pub(crate) fn out_port_at(cx: &ToolContext, p: Point) -> Option<NodeId> {
|
||||
let h = cx.tol(PORT_HALF_PX + 1.0);
|
||||
let near = |t: &TextObject| out_port(t, cx).is_some_and(|c| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h);
|
||||
cx.selection.objects.iter().copied().find(|id| {
|
||||
cx.doc
|
||||
.node(*id)
|
||||
.filter(|n| crate::select::is_area_type(n))
|
||||
.and_then(|_| TypeTool::text(cx, *id))
|
||||
.is_some_and(|t| near(t) && vectorcraft_text::layout(FontDb::global(), t).overflow)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which selected text overflows, for [`overflow_markers`]: each text object's layout verdict kept
|
||||
/// with the object and the fonts it was made from, as overlays run every frame.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
//! Create Strokes: the parts of a colour layer no wider than the Stroke width, traced as stroked
|
||||
//! centre lines instead of filled outlines.
|
||||
//!
|
||||
//! 1. **Distance**: a chamfer (3-4) distance transform gives each pixel its distance to the nearest
|
||||
//! pixel outside the layer.
|
||||
//! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less
|
||||
//! one) is within the stroke width is a line; wider ones stay filled areas.
|
||||
//! 3. **Skeleton**: the line is thinned to one pixel (Zhang–Suen, applied pixel by pixel so it
|
||||
//! keeps its topology and two-pixel-wide lines don't vanish), then staircase corners that
|
||||
//! don't connect anything are dropped.
|
||||
//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels
|
||||
//! merged into one point) become polylines through the pixel centres; rings without ends
|
||||
//! become closed ones. Short spurs that thinning leaves at junctions are pruned and the runs
|
||||
//! on either side of a junction left with two of them are joined.
|
||||
//! 5. **Ink**: the skeleton is right to a pixel only, so each of its points is moved to the middle
|
||||
//! of the line's ink, read from the image: across the line (along the pixel column or row most
|
||||
//! nearly square to it) each pixel is as much ink as its colour is the line's rather than the
|
||||
//! paper's, and the ink's centroid is the line's middle there and its sum the line's width (an
|
||||
//! anti-aliased or blurred line says both to a fraction of a pixel). The stroke width is the
|
||||
//! median of those widths, and each end is put where the ink ends, less the half width a round
|
||||
//! cap adds.
|
||||
//! 6. **Curves**: each polyline is fitted as outlines are ([`fit::fit_piece`]): a straight line
|
||||
//! is two anchors, corners stay corners, the rest is as few curves as Paths allows.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
|
||||
use crate::Raster;
|
||||
use crate::fit;
|
||||
use crate::logo::Style;
|
||||
|
||||
/// One line of a colour layer: its centre lines and the width to stroke them with.
|
||||
pub(crate) struct Line {
|
||||
pub path: PathData,
|
||||
/// Stroke width in pixels.
|
||||
pub width: f64,
|
||||
pub pixels: usize,
|
||||
/// Its ends are cut square (the ink is as long beside the line's middle as along it), not round.
|
||||
pub square: bool,
|
||||
}
|
||||
|
||||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||||
/// pixels, and which pixels they cover (to leave out of the filled areas).
|
||||
/// `ink` is the colour of the layer's ink in `img` (its palette colour; in Black and White the
|
||||
/// image's own black), which the lines' middles and widths are read against.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn lines(
|
||||
labels: &[u16],
|
||||
label: u16,
|
||||
w: usize,
|
||||
h: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
) -> (Vec<bool>, Vec<Line>) {
|
||||
let mut thin = vec![false; labels.len()];
|
||||
let mut out = vec![];
|
||||
if w == 0 || h == 0 || labels.len() != w * h {
|
||||
return (thin, out);
|
||||
}
|
||||
let mask = |i: usize| labels.get(i) == Some(&label);
|
||||
let dt = distance(&mask, w, h);
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack = vec![];
|
||||
for start in 0..w * h {
|
||||
if !mask(start) || seen[start] {
|
||||
continue;
|
||||
}
|
||||
let mut comp = vec![];
|
||||
seen[start] = true;
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
comp.push(i);
|
||||
for j in neighbours(i, w, h) {
|
||||
if mask(j) && !seen[j] {
|
||||
seen[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
let widest = 2.0 * f64::from(comp.iter().map(|&i| dt[i]).max().unwrap_or(0)) / 3.0 - 1.0;
|
||||
if widest > max_width {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = trace_line(&comp, w, max_width, img, ink, style) else { continue };
|
||||
for &i in &comp {
|
||||
thin[i] = true;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
(thin, out)
|
||||
}
|
||||
|
||||
/// The 8 neighbours of pixel `i` inside the `w` × `h` image.
|
||||
fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
const AROUND: [(i64, i64); 8] = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)];
|
||||
AROUND.into_iter().filter_map(move |(dx, dy)| {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
(nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| ny as usize * w + nx as usize)
|
||||
})
|
||||
}
|
||||
|
||||
/// Chamfer 3-4 distance (3 per pixel step, saturating) of each pixel in `mask` to the nearest
|
||||
/// pixel outside it; outside the image counts as outside.
|
||||
fn distance(mask: &impl Fn(usize) -> bool, w: usize, h: usize) -> Vec<u16> {
|
||||
let mut d = vec![0u16; w * h];
|
||||
let at = |d: &[u16], x: i64, y: i64| {
|
||||
if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { 0 } else { d.get(y as usize * w + x as usize).copied().unwrap_or(0) }
|
||||
};
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if mask(i) {
|
||||
let (x, y) = (x as i64, y as i64);
|
||||
let steps = [(x - 1, y, 3), (x - 1, y - 1, 4), (x, y - 1, 3), (x + 1, y - 1, 4)];
|
||||
d[i] = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in (0..h).rev() {
|
||||
for x in (0..w).rev() {
|
||||
let i = y * w + x;
|
||||
if mask(i) {
|
||||
let (xi, yi) = (x as i64, y as i64);
|
||||
let steps = [(xi + 1, yi, 3), (xi + 1, yi + 1, 4), (xi, yi + 1, 3), (xi - 1, yi + 1, 4)];
|
||||
let v = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
|
||||
d[i] = d[i].min(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// A grid around one line: its pixels with a one-pixel empty border.
|
||||
struct Grid {
|
||||
on: Vec<bool>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
/// Image position of grid pixel (0, 0).
|
||||
x0: i64,
|
||||
y0: i64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// The 8 neighbours (N, NE, E, SE, S, SW, W, NW) of interior pixel `i`.
|
||||
fn ring(&self, i: usize) -> [bool; 8] {
|
||||
let w = self.w;
|
||||
let g = |j: usize| self.on.get(j).copied().unwrap_or(false);
|
||||
[g(i - w), g(i - w + 1), g(i + 1), g(i + w + 1), g(i + w), g(i + w - 1), g(i - 1), g(i - w - 1)]
|
||||
}
|
||||
fn interior(&self) -> impl Iterator<Item = usize> + use<> {
|
||||
let (w, h) = (self.w, self.h);
|
||||
(1..h.saturating_sub(1)).flat_map(move |y| (1..w.saturating_sub(1)).map(move |x| y * w + x))
|
||||
}
|
||||
fn neighbours(&self, i: usize) -> impl Iterator<Item = usize> + '_ {
|
||||
let w = self.w;
|
||||
[i - w, i - w + 1, i + 1, i + w + 1, i + w, i + w - 1, i - 1, i - w - 1].into_iter().filter(|&j| self.on.get(j).copied().unwrap_or(false))
|
||||
}
|
||||
fn degree(&self, i: usize) -> usize {
|
||||
self.neighbours(i).count()
|
||||
}
|
||||
/// The centre of pixel `i` in image coordinates.
|
||||
fn centre(&self, i: usize) -> Point {
|
||||
Point::new((self.x0 + (i % self.w) as i64) as f64 + 0.5, (self.y0 + (i / self.w) as i64) as f64 + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zhang–Suen thinning. Each sub-iteration picks the pixels to remove from the same state, so the
|
||||
/// skeleton stays centred; they are then removed one by one, each only if it still doesn't
|
||||
/// disconnect anything, so a two-pixel-wide line thins to one pixel instead of vanishing.
|
||||
fn thin(g: &mut Grid) {
|
||||
let removable = |p: &[bool; 8]| {
|
||||
let b = p.iter().filter(|v| **v).count();
|
||||
let a = (0..8).filter(|&k| !p[k] && p[(k + 1) % 8]).count();
|
||||
(2..=6).contains(&b) && a == 1
|
||||
};
|
||||
let mut picked = vec![];
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for step in 0..2 {
|
||||
picked.clear();
|
||||
for i in g.interior() {
|
||||
if !g.on[i] {
|
||||
continue;
|
||||
}
|
||||
let p = g.ring(i);
|
||||
let (n, e, s, w) = (p[0], p[2], p[4], p[6]);
|
||||
let side = if step == 0 { !(e && s && (n || w)) } else { !(n && w && (e || s)) };
|
||||
if side && removable(&p) {
|
||||
picked.push(i);
|
||||
}
|
||||
}
|
||||
for &i in &picked {
|
||||
if removable(&g.ring(i)) {
|
||||
g.on[i] = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Staircase corners: a pixel whose neighbours stay connected without it (Yokoi's
|
||||
// 8-connectivity number 1) and that doesn't end the line.
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for i in g.interior() {
|
||||
if !g.on[i] || g.degree(i) < 2 {
|
||||
continue;
|
||||
}
|
||||
let p = g.ring(i);
|
||||
// x1 = E, x2 = NE, x3 = N, x4 = NW, x5 = W, x6 = SW, x7 = S, x8 = SE.
|
||||
let x = [p[2], p[1], p[0], p[7], p[6], p[5], p[4], p[3]];
|
||||
let off = |k: usize| !x[k % 8];
|
||||
let c8: usize = [0, 2, 4, 6].iter().map(|&k| usize::from(off(k)) - usize::from(off(k) && off(k + 1) && off(k + 2))).sum();
|
||||
if c8 == 1 {
|
||||
g.on[i] = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of skeleton pixels between two nodes (`None`: none, a ring).
|
||||
#[derive(Clone)]
|
||||
struct Run {
|
||||
pts: Vec<Point>,
|
||||
ends: [Option<usize>; 2],
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn length(&self) -> f64 {
|
||||
self.pts.windows(2).map(|p| p[0].distance(p[1])).sum::<f64>()
|
||||
+ if self.closed { self.pts.first().zip(self.pts.last()).map_or(0.0, |(a, b)| a.distance(*b)) } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||||
fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
|
||||
let xs = comp.iter().map(|&i| i % w);
|
||||
let ys = comp.iter().map(|&i| i / w);
|
||||
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
|
||||
let (gw, gh) = (x1 - x0 + 3, y1 - y0 + 3);
|
||||
let mut g = Grid { on: vec![false; gw * gh], w: gw, h: gh, x0: x0 as i64 - 1, y0: y0 as i64 - 1 };
|
||||
for &i in comp {
|
||||
g.on[(i / w - y0 + 1) * gw + (i % w - x0 + 1)] = true;
|
||||
}
|
||||
thin(&mut g);
|
||||
let skeleton: Vec<usize> = g.interior().filter(|&i| g.on[i]).collect();
|
||||
if skeleton.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// Nodes: line ends, and junctions with the junction pixels touching them.
|
||||
let mut node = vec![usize::MAX; g.on.len()];
|
||||
let mut centres: Vec<Point> = vec![];
|
||||
for &i in &skeleton {
|
||||
let d = g.degree(i);
|
||||
if d == 2 || node[i] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let id = centres.len();
|
||||
let mut members = vec![i];
|
||||
node[i] = id;
|
||||
if d > 2 {
|
||||
let mut k = 0;
|
||||
while let Some(&p) = members.get(k) {
|
||||
k += 1;
|
||||
for q in g.neighbours(p).collect::<Vec<_>>() {
|
||||
if node[q] == usize::MAX && g.degree(q) > 2 {
|
||||
node[q] = id;
|
||||
members.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let sum = members.iter().fold(Point::ZERO, |s, &m| s + g.centre(m).to_vec2());
|
||||
centres.push(Point::new(sum.x / members.len() as f64, sum.y / members.len() as f64));
|
||||
}
|
||||
// Runs from each node, then the rings left.
|
||||
let mut used: HashSet<(usize, usize)> = HashSet::new();
|
||||
let edge = |a: usize, b: usize| (a.min(b), a.max(b));
|
||||
let mut runs: Vec<Run> = vec![];
|
||||
for &p in skeleton.iter().filter(|&&i| node[i] != usize::MAX) {
|
||||
for q in g.neighbours(p).collect::<Vec<_>>() {
|
||||
if node[q] == node[p] || !used.insert(edge(p, q)) {
|
||||
continue;
|
||||
}
|
||||
let mut pts = vec![centres[node[p]], g.centre(q)];
|
||||
let (mut prev, mut cur) = (p, q);
|
||||
while node[cur] == usize::MAX {
|
||||
let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) else { break };
|
||||
used.insert(edge(cur, next));
|
||||
pts.push(g.centre(next));
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
let end = (node[cur] != usize::MAX).then_some(node[cur]);
|
||||
if let (Some(e), Some(last)) = (end, pts.last_mut()) {
|
||||
*last = centres[e];
|
||||
}
|
||||
runs.push(Run { pts, ends: [Some(node[p]), end], closed: false });
|
||||
}
|
||||
}
|
||||
for &s in &skeleton {
|
||||
if node[s] != usize::MAX || g.neighbours(s).any(|n| used.contains(&edge(s, n))) {
|
||||
continue;
|
||||
}
|
||||
let mut pts = vec![g.centre(s)];
|
||||
let (mut prev, mut cur) = (usize::MAX, s);
|
||||
let mut closed = false;
|
||||
while let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) {
|
||||
used.insert(edge(cur, next));
|
||||
if next == s {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
pts.push(g.centre(next));
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
runs.push(Run { pts, ends: [None, None], closed });
|
||||
}
|
||||
// The line's mean width: its pixels over its length (the skeleton stops about half a width
|
||||
// short of each end).
|
||||
let width = |runs: &[Run]| {
|
||||
let length: f64 = runs.iter().map(Run::length).sum();
|
||||
let px = comp.len() as f64;
|
||||
((-length + (length * length + 4.0 * px).sqrt()) / 2.0).clamp(1.0, max_width.max(1.0))
|
||||
};
|
||||
let spur = width(&runs);
|
||||
let runs = join(prune(runs, centres.len(), spur), centres.len());
|
||||
let mut runs = runs;
|
||||
let coarse = width(&runs);
|
||||
let ink = Ink { img, color: ink, reach: (max_width / 2.0).ceil().clamp(1.0, 64.0) as i64 + 2 };
|
||||
// How many runs end at each node: one is an end of the line.
|
||||
let mut meet = vec![0usize; centres.len()];
|
||||
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
|
||||
meet[*e] += 1;
|
||||
}
|
||||
let (mut widths, mut soft, mut read) = (vec![], 0usize, 0usize);
|
||||
for r in &mut runs {
|
||||
let n = r.pts.len();
|
||||
let old = r.pts.clone();
|
||||
for i in 0..n {
|
||||
// A junction stays where its pixels put it.
|
||||
let end = if i == 0 {
|
||||
r.ends[0]
|
||||
} else if i + 1 == n {
|
||||
r.ends[1]
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if end.is_some_and(|e| meet[e] > 1) {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = if r.closed { (old[(i + n - 2 % n) % n], old[(i + 2) % n]) } else { (old[i.saturating_sub(2)], old[(i + 2).min(n - 1)]) };
|
||||
let d = b - a;
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let upright = d.x.abs() >= d.y.abs();
|
||||
let Some(across) = ink.across(old[i], upright) else { continue };
|
||||
r.pts[i] = old[i] + if upright { kurbo::Vec2::new(0.0, across.middle) } else { kurbo::Vec2::new(across.middle, 0.0) };
|
||||
widths.push(across.width * if upright { d.x.abs() } else { d.y.abs() } / d.hypot());
|
||||
read += 1;
|
||||
soft += usize::from(across.soft);
|
||||
}
|
||||
}
|
||||
// The stroke is as wide as the ink at the line's middle point (where no ink could be read, as
|
||||
// wide as its pixels make it).
|
||||
widths.sort_by(f64::total_cmp);
|
||||
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
|
||||
// Each end goes where the ink ends, less the half width its cap adds. The cap is square when
|
||||
// the ends wide enough to tell are, taken together: the ink reaches as far a third of the width to either side
|
||||
// of the line's middle as along it, where a round end stops short by a seventh of the width.
|
||||
let (mut short_by, mut told) = (0.0, 0);
|
||||
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
|
||||
for k in 0..2 {
|
||||
if r.ends[k].is_some_and(|e| meet[e] > 1) {
|
||||
continue;
|
||||
}
|
||||
let n = r.pts.len();
|
||||
let at = |i: usize| if k == 0 { i.min(n - 1) } else { n - 1 - i.min(n - 1) };
|
||||
// The last points of a wide line's skeleton turn towards a corner of its end: the end
|
||||
// lies where the line was going a width back, and they are put on that line.
|
||||
let back = (width.round() as usize + 2).max(3);
|
||||
let (tip, inner) = (r.pts[at(0)], r.pts[at(back)]);
|
||||
// (Only an end that has left that line: over so few pixels the line's direction is known
|
||||
// to a few degrees at best, less without anti-aliasing, and an end already on it is
|
||||
// better left where it is.)
|
||||
let before = inner - r.pts[at(2 * back)];
|
||||
let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot();
|
||||
let d = if wide { before } else { tip - inner };
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let along = d / d.hypot();
|
||||
let on_line = |p: Point| inner + along * (p - inner).dot(along).max(0.0);
|
||||
if wide {
|
||||
for i in 0..back {
|
||||
r.pts[at(i)] = on_line(r.pts[at(i)]);
|
||||
}
|
||||
}
|
||||
let tip = on_line(tip);
|
||||
let reach = ink.beyond(tip, along);
|
||||
if width >= 3.0 {
|
||||
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
|
||||
let short = reach - (ink.beyond(tip + aside, along) + ink.beyond(tip - aside, along)) / 2.0;
|
||||
short_by += short / width;
|
||||
told += 1;
|
||||
}
|
||||
let more = reach - width / 2.0;
|
||||
if more.abs() > 0.05 && more > 1.0 - (tip - inner).hypot() {
|
||||
r.pts[at(0)] = tip + along * more;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut style = *style;
|
||||
if read > 0 && soft * 4 < read {
|
||||
// Hard pixels: the middle is only known to about half a pixel (as for outlines).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 2.5;
|
||||
style.sigma = 1.5;
|
||||
}
|
||||
let subs: Vec<_> = runs
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if r.closed { fit::subpath(&fit::fit_closed(&r.pts, &style)?, true) } else { fit::subpath(&fit::fit_piece(&r.pts, &style, false), false) }
|
||||
})
|
||||
.collect();
|
||||
if subs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line { path: PathData::new(subs), width, pixels: comp.len(), square: told > 0 && short_by / f64::from(told) < 0.07 })
|
||||
}
|
||||
|
||||
/// What the image says of a line's ink.
|
||||
struct Ink<'a> {
|
||||
img: &'a Raster,
|
||||
/// The line's colour.
|
||||
color: [f64; 3],
|
||||
/// How far (pixels) from the skeleton the ink is looked for.
|
||||
reach: i64,
|
||||
}
|
||||
|
||||
/// The ink across a line at one point: where its middle lies from the point, how wide it is, and
|
||||
/// whether its edges are soft (some pixel is part ink, part paper).
|
||||
struct Across {
|
||||
middle: f64,
|
||||
width: f64,
|
||||
soft: bool,
|
||||
}
|
||||
|
||||
impl Ink<'_> {
|
||||
/// How far the colour of pixel `(x, y)` is from the line's (`None` off the image).
|
||||
fn far(&self, x: i64, y: i64) -> Option<f64> {
|
||||
let (w, h) = (i64::from(self.img.width), i64::from(self.img.height));
|
||||
if x < 0 || y < 0 || x >= w || y >= h {
|
||||
return None;
|
||||
}
|
||||
let at = (y * w + x) as usize * 4;
|
||||
let p = self.img.rgba.get(at..at + 3)?;
|
||||
Some((0..3).map(|k| (f64::from(p[k]) - self.color[k]).powi(2)).sum::<f64>().sqrt())
|
||||
}
|
||||
/// How far the paper's colour is from the line's around pixel `(x, y)`: the farthest colour
|
||||
/// within reach, along its row and its column.
|
||||
fn paper(&self, x: i64, y: i64) -> f64 {
|
||||
(-self.reach..=self.reach).flat_map(|k| [self.far(x + k, y), self.far(x, y + k)]).flatten().fold(0.0, f64::max)
|
||||
}
|
||||
/// The ink across the line through the pixel `p` lies in: along its column (`upright`) or row.
|
||||
fn across(&self, p: Point, upright: bool) -> Option<Across> {
|
||||
let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
|
||||
let paper = self.paper(x, y);
|
||||
// Paper too like the ink says nothing.
|
||||
if paper < 24.0 {
|
||||
return None;
|
||||
}
|
||||
let tone = |k: i64| (if upright { self.far(x, y + k) } else { self.far(x + k, y) }).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
// From the pixel with most ink near the skeleton's (thinning leaves it up to a pixel or two
|
||||
// off the line's darkest row) out to where the ink ends, or rises again (another line).
|
||||
let peak = [0, -1, 1, -2, 2].into_iter().fold(0, |best, k| if tone(k) > tone(best) + 1e-9 { k } else { best });
|
||||
let here = tone(peak);
|
||||
let (mut sum, mut moment, mut soft) = (here, here * peak as f64, here > 0.1 && here < 0.9);
|
||||
for side in [-1, 1] {
|
||||
let mut last = here;
|
||||
for k in 1..=self.reach {
|
||||
let t = tone(peak + side * k);
|
||||
if t < 0.04 || t > last + 0.1 {
|
||||
break;
|
||||
}
|
||||
sum += t;
|
||||
moment += t * (peak + side * k) as f64;
|
||||
soft |= t > 0.1 && t < 0.9;
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
// The pixel's centre is at `p`'s own half-pixel; the middle is measured from `p`.
|
||||
let centre = if upright { y as f64 + 0.5 - p.y } else { x as f64 + 0.5 - p.x };
|
||||
(sum > 0.0).then(|| Across { middle: centre + moment / sum, width: sum, soft })
|
||||
}
|
||||
/// How much ink (pixels) lies beyond `tip`, going `dir` (a unit vector).
|
||||
fn beyond(&self, tip: Point, dir: kurbo::Vec2) -> f64 {
|
||||
let paper = self.paper(tip.x.floor() as i64, tip.y.floor() as i64);
|
||||
if paper < 24.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for step in 1..=4 * (self.reach + 2) {
|
||||
let q = tip + dir * (step as f64 * 0.25);
|
||||
let t = self.far(q.x.floor() as i64, q.y.floor() as i64).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
if t < 0.04 {
|
||||
break;
|
||||
}
|
||||
sum += 0.25 * t;
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
|
||||
/// The runs without the spurs thinning leaves: runs shorter than the line's `width` from a line end
|
||||
/// to a junction that keeps at least two other runs.
|
||||
fn prune(runs: Vec<Run>, nodes: usize, width: f64) -> Vec<Run> {
|
||||
let mut degree = vec![0usize; nodes];
|
||||
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
|
||||
degree[*e] += 1;
|
||||
}
|
||||
let mut order: Vec<usize> = (0..runs.len()).collect();
|
||||
order.sort_by(|a, b| runs[*a].length().total_cmp(&runs[*b].length()));
|
||||
let mut keep = vec![true; runs.len()];
|
||||
for i in order {
|
||||
let r = &runs[i];
|
||||
let [Some(a), Some(b)] = r.ends else { continue };
|
||||
let (tip, fork) = if degree[a] == 1 { (a, b) } else { (b, a) };
|
||||
if degree[tip] == 1 && degree[fork] >= 3 && r.length() < width.max(2.0) {
|
||||
keep[i] = false;
|
||||
degree[tip] -= 1;
|
||||
degree[fork] -= 1;
|
||||
}
|
||||
}
|
||||
runs.into_iter().zip(keep).filter_map(|(r, k)| k.then_some(r)).collect()
|
||||
}
|
||||
|
||||
/// The runs joined through every node exactly two of them meet at (a run meeting itself closes).
|
||||
fn join(runs: Vec<Run>, nodes: usize) -> Vec<Run> {
|
||||
let mut runs: Vec<Option<Run>> = runs.into_iter().map(Some).collect();
|
||||
for n in 0..nodes {
|
||||
let at: Vec<(usize, usize)> = runs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, r)| r.iter().flat_map(move |r| (0..2).filter(move |&k| r.ends[k] == Some(n)).map(move |k| (i, k))))
|
||||
.collect();
|
||||
let [(i, ki), (j, kj)] = at[..] else { continue };
|
||||
if i == j {
|
||||
if let Some(r) = runs[i].as_mut() {
|
||||
r.pts.pop();
|
||||
r.ends = [None, None];
|
||||
r.closed = r.pts.len() >= 3;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (Some(mut a), Some(mut b)) = (runs[i].take(), runs[j].take()) else { continue };
|
||||
// a ends at n, b starts at n.
|
||||
if ki == 0 {
|
||||
a.pts.reverse();
|
||||
a.ends.reverse();
|
||||
}
|
||||
if kj == 1 {
|
||||
b.pts.reverse();
|
||||
b.ends.reverse();
|
||||
}
|
||||
a.pts.extend(b.pts.into_iter().skip(1));
|
||||
a.ends[1] = b.ends[1];
|
||||
runs[i] = Some(a);
|
||||
}
|
||||
runs.into_iter().flatten().collect()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Exact squared Euclidean distance, Felzenszwalb & Huttenlocher, "Distance Transforms of
|
||||
//! Sampled Functions", §3. Separable lower envelopes of parabolas, linear time and crop storage.
|
||||
|
||||
const FAR: f64 = 1e20;
|
||||
|
||||
/// Transform one row or column. Each envelope entry is (sample, left intersection).
|
||||
fn transform(f: &[f64], out: &mut [f64], envelope: &mut Vec<(usize, f64)>) -> Option<()> {
|
||||
envelope.clear();
|
||||
for (q, &fq) in f.iter().enumerate() {
|
||||
let mut left = -FAR;
|
||||
while let Some(&(v, z)) = envelope.last() {
|
||||
let (qf, vf) = (q as f64, v as f64);
|
||||
left = ((fq + qf * qf) - (*f.get(v)? + vf * vf)) / (2.0 * (qf - vf));
|
||||
if left > z {
|
||||
break;
|
||||
}
|
||||
envelope.pop();
|
||||
}
|
||||
if envelope.is_empty() {
|
||||
left = -FAR;
|
||||
}
|
||||
envelope.push((q, left));
|
||||
}
|
||||
let mut k = 0;
|
||||
for (q, d) in out.iter_mut().enumerate() {
|
||||
while envelope.get(k + 1).is_some_and(|&(_, z)| z < q as f64) {
|
||||
k += 1;
|
||||
}
|
||||
let &(v, _) = envelope.get(k)?;
|
||||
*d = (q as f64 - v as f64).powi(2) + *f.get(v)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Squared centre-to-paper distances for a validated, paper-bordered component crop.
|
||||
/// Border paper also represents the space outside the raster. Returns None for invalid sizes
|
||||
/// or a non-paper border, rather than accepting an unbounded allocation or an absent seed.
|
||||
pub(super) fn squared(mask: &[bool], w: usize, h: usize) -> Option<Vec<f32>> {
|
||||
let size = super::crop_size(w, h)?;
|
||||
if mask.len() != size || mask.iter().enumerate().any(|(i, &on)| on && (i < w || i >= size - w || i % w == 0 || i % w + 1 == w)) {
|
||||
return None;
|
||||
}
|
||||
let mut grid: Vec<f64> = mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect();
|
||||
let n = w.max(h);
|
||||
let (mut f, mut d, mut envelope) = (vec![0.0; n], vec![0.0; n], Vec::with_capacity(n));
|
||||
for x in 0..w {
|
||||
for (y, v) in f.get_mut(..h)?.iter_mut().enumerate() {
|
||||
*v = *grid.get(y * w + x)?;
|
||||
}
|
||||
transform(f.get(..h)?, d.get_mut(..h)?, &mut envelope)?;
|
||||
for (y, &v) in d.get(..h)?.iter().enumerate() {
|
||||
*grid.get_mut(y * w + x)? = v;
|
||||
}
|
||||
}
|
||||
for row in grid.chunks_exact_mut(w) {
|
||||
for (v, &g) in f.get_mut(..w)?.iter_mut().zip(row.iter()) {
|
||||
*v = g;
|
||||
}
|
||||
transform(f.get(..w)?, d.get_mut(..w)?, &mut envelope)?;
|
||||
for (g, &v) in row.iter_mut().zip(d.get(..w)?) {
|
||||
*g = v;
|
||||
}
|
||||
}
|
||||
Some(grid.into_iter().map(|v| v as f32).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn edt_equals_brute_force_on_random_masks() {
|
||||
let mut seed = 19u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
(seed >> 33) as usize
|
||||
};
|
||||
for case in 0..200 {
|
||||
let (w, h) = (3 + rnd() % 24, 3 + rnd() % 24);
|
||||
let mask: Vec<bool> =
|
||||
(0..w * h).map(|i| i >= w && i < w * (h - 1) && i % w != 0 && i % w + 1 < w && (case % 5 == 0 || rnd() % 4 != 0)).collect();
|
||||
let d = squared(&mask, w, h).unwrap();
|
||||
for (i, &actual) in d.iter().enumerate() {
|
||||
let expected = mask
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(_, &on)| !on)
|
||||
.map(|(j, _)| {
|
||||
let dx = (i % w).abs_diff(j % w);
|
||||
let dy = (i / w).abs_diff(j / w);
|
||||
(dx * dx + dy * dy) as f32
|
||||
})
|
||||
.min_by(f32::total_cmp)
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected, "case {case}, pixel {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_crops_are_refused() {
|
||||
assert!(squared(&[], 0, 0).is_none());
|
||||
assert!(squared(&[], usize::MAX, 3).is_none());
|
||||
assert!(squared(&[false; 9], 4, 3).is_none());
|
||||
assert!(squared(&[true; 9], 3, 3).is_none());
|
||||
assert!(squared(&[], crate::MAX_PIXELS as usize + 3, 3).is_none());
|
||||
assert_eq!(squared(&[false; 9], 3, 3), Some(vec![0.0; 9]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
//! The skeleton as a graph: nodes (line ends, junctions) and branches (pixel runs between them),
|
||||
//! with spur pruning and junction merging.
|
||||
//!
|
||||
//! Pruning follows the usual significance test for medial-axis branches (Blum 1967; see also
|
||||
//! Shaked & Bruckstein 1998, "Pruning medial axes", CVIU 69(2)): a terminal branch that reaches
|
||||
//! less than a threshold beyond the maximal disc of the junction it hangs from adds almost
|
||||
//! nothing to the shape the skeleton reconstructs, so it is a boundary artefact (a spur). The
|
||||
//! threshold is tied to the line width: `max(spur_min, spur_k · min(2 r, stroke_width))`.
|
||||
//! Two junctions whose maximal discs overlap are one junction that thinning split in two (the
|
||||
//! crossing of two thick lines thins to two forks joined by a short bridge).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use kurbo::Vec2;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::Grid;
|
||||
|
||||
const MAX_BRANCHES: usize = 200_000;
|
||||
const MAX_NODES: usize = 200_000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Node {
|
||||
/// Position (pixel centre of its deepest pixel, or a merged junction's middle).
|
||||
pub pos: Point,
|
||||
/// Radius of the node's maximal disc (distance to the ink's edge), px.
|
||||
pub r: f64,
|
||||
/// A junction (thinning met three or more runs here) rather than a line end.
|
||||
pub junction: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Branch {
|
||||
/// Points from one node to the other (end points are the nodes' positions).
|
||||
pub pts: Vec<Point>,
|
||||
/// Distance transform at each point (px, centre to nearest paper centre).
|
||||
pub d: Vec<f64>,
|
||||
/// End nodes; both `None` for a ring.
|
||||
pub ends: [Option<usize>; 2],
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl Branch {
|
||||
pub(super) fn length(&self) -> f64 {
|
||||
self.pts.windows(2).map(|w| w[0].distance(w[1])).sum::<f64>()
|
||||
+ if self.closed { self.pts.first().zip(self.pts.last()).map_or(0.0, |(a, b)| a.distance(*b)) } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(super) struct Graph {
|
||||
pub nodes: Vec<Node>,
|
||||
pub branches: Vec<Branch>,
|
||||
}
|
||||
|
||||
/// Build one paper-bordered crop. Distances use this same crop and are squared.
|
||||
/// Refuse an over-budget graph in full so the caller can retain the blob as a fill.
|
||||
pub(super) fn build(g: &Grid, dist: &[f32]) -> Option<Graph> {
|
||||
build_with_limits(g, dist, MAX_NODES, MAX_BRANCHES)
|
||||
}
|
||||
|
||||
fn build_with_limits(g: &Grid, dist: &[f32], max_nodes: usize, max_branches: usize) -> Option<Graph> {
|
||||
let n = super::crop_size(g.w, g.h)?;
|
||||
if g.on.len() != n
|
||||
|| dist.len() != n
|
||||
|| dist.iter().any(|d| !d.is_finite() || *d < 0.0)
|
||||
|| g.on.iter().enumerate().any(|(i, &on)| on && !g.is_interior(i))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let dval = |i: usize| f64::from(dist.get(i).copied().unwrap_or(0.0)).sqrt();
|
||||
let deg: Vec<u8> = (0..n).map(|i| if g.on.get(i) == Some(&true) { g.degree(i) as u8 } else { 0 }).collect();
|
||||
let mut node_of = vec![usize::MAX; n];
|
||||
let mut graph = Graph::default();
|
||||
// Ends on their own; touching junction pixels together, deepest pixel first on ties.
|
||||
for i in g.interior() {
|
||||
let degree = *deg.get(i)?;
|
||||
if g.on.get(i) != Some(&true) || degree == 2 || *node_of.get(i)? != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
// Isolated pixels cannot form a stroke; leave them to the existing fill path.
|
||||
if degree == 0 {
|
||||
continue;
|
||||
}
|
||||
if graph.nodes.len() >= max_nodes.min(MAX_NODES) {
|
||||
return None;
|
||||
}
|
||||
let id = graph.nodes.len();
|
||||
*node_of.get_mut(i)? = id;
|
||||
let mut members = vec![i];
|
||||
if degree > 2 {
|
||||
let mut k = 0;
|
||||
while let Some(&p) = members.get(k) {
|
||||
k += 1;
|
||||
for q in g.neighbours(p) {
|
||||
if *node_of.get(q)? == usize::MAX && *deg.get(q)? > 2 {
|
||||
*node_of.get_mut(q)? = id;
|
||||
members.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = members.into_iter().max_by(|&a, &b| dval(a).total_cmp(&dval(b)).then(b.cmp(&a)))?;
|
||||
graph.nodes.push(Node { pos: g.centre(best), r: (dval(best) - 0.5).max(0.5), junction: degree > 2 });
|
||||
}
|
||||
let mut seen_edge = BTreeSet::new();
|
||||
let mut visited = vec![false; n];
|
||||
for p in g.interior() {
|
||||
let a = *node_of.get(p)?;
|
||||
if a == usize::MAX {
|
||||
continue;
|
||||
}
|
||||
for q in g.neighbours(p) {
|
||||
let b = *node_of.get(q)?;
|
||||
if b == a {
|
||||
continue;
|
||||
}
|
||||
if b != usize::MAX {
|
||||
if seen_edge.insert((p.min(q), p.max(q))) {
|
||||
graph.push_branch(
|
||||
Branch {
|
||||
pts: vec![graph.nodes.get(a)?.pos, graph.nodes.get(b)?.pos],
|
||||
d: vec![dval(p), dval(q)],
|
||||
ends: [Some(a), Some(b)],
|
||||
closed: false,
|
||||
},
|
||||
max_branches,
|
||||
)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *visited.get(q)? {
|
||||
continue;
|
||||
}
|
||||
let mut px = vec![p];
|
||||
let (mut prev, mut cur) = (p, q);
|
||||
let mut guard = n;
|
||||
while *node_of.get(cur)? == usize::MAX && guard > 0 {
|
||||
guard -= 1;
|
||||
*visited.get_mut(cur)? = true;
|
||||
px.push(cur);
|
||||
let Some(next) = g.neighbours(cur).find(|&m| m != prev && (visited.get(m) == Some(&false) || node_of.get(m) != Some(&usize::MAX)))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
if guard == 0 {
|
||||
return None;
|
||||
}
|
||||
let end = (*node_of.get(cur)? != usize::MAX).then_some(*node_of.get(cur)?);
|
||||
if end.is_some() {
|
||||
px.push(cur);
|
||||
}
|
||||
let mut pts: Vec<Point> = px.iter().map(|&i| g.centre(i)).collect();
|
||||
let d = px.into_iter().map(dval).collect();
|
||||
*pts.first_mut()? = graph.nodes.get(a)?.pos;
|
||||
if let Some(e) = end {
|
||||
*pts.last_mut()? = graph.nodes.get(e)?.pos;
|
||||
}
|
||||
graph.push_branch(Branch { pts, d, ends: [Some(a), end], closed: false }, max_branches)?;
|
||||
}
|
||||
}
|
||||
// Rings never reached from a node.
|
||||
for s in g.interior() {
|
||||
if g.on.get(s) != Some(&true) || deg.get(s) != Some(&2) || *visited.get(s)? || *node_of.get(s)? != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let mut px = vec![];
|
||||
let (mut prev, mut cur) = (usize::MAX, s);
|
||||
let mut closed = false;
|
||||
let mut guard = n;
|
||||
while guard > 0 {
|
||||
guard -= 1;
|
||||
*visited.get_mut(cur)? = true;
|
||||
px.push(cur);
|
||||
let Some(next) = g.neighbours(cur).find(|&m| m != prev && visited.get(m) == Some(&false)) else {
|
||||
closed = g.neighbours(cur).any(|m| m == s) && px.len() > 2;
|
||||
break;
|
||||
};
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
if guard == 0 {
|
||||
return None;
|
||||
}
|
||||
graph.push_branch(
|
||||
Branch { pts: px.iter().map(|&i| g.centre(i)).collect(), d: px.into_iter().map(dval).collect(), ends: [None, None], closed },
|
||||
max_branches,
|
||||
)?;
|
||||
}
|
||||
Some(graph)
|
||||
}
|
||||
|
||||
impl Graph {
|
||||
fn push_branch(&mut self, branch: Branch, limit: usize) -> Option<()> {
|
||||
if self.branches.len() >= limit.min(MAX_BRANCHES) {
|
||||
return None;
|
||||
}
|
||||
self.branches.push(branch);
|
||||
Some(())
|
||||
}
|
||||
pub(super) fn degrees(&self) -> Option<Vec<usize>> {
|
||||
let mut deg = vec![0usize; self.nodes.len()];
|
||||
for b in &self.branches {
|
||||
for e in b.ends.iter().flatten() {
|
||||
*deg.get_mut(*e)? += 1;
|
||||
}
|
||||
}
|
||||
Some(deg)
|
||||
}
|
||||
|
||||
/// Removes spurs, drops tiny self-loops, merges junctions whose discs overlap and joins runs
|
||||
/// through nodes left with two of them; repeated until nothing changes (at most 32 rounds).
|
||||
pub(super) fn simplify(&mut self, stroke_width: f64) -> Option<()> {
|
||||
if !stroke_width.is_finite() || stroke_width <= 0.0 || self.nodes.len() > MAX_NODES || self.branches.len() > MAX_BRANCHES {
|
||||
return None;
|
||||
}
|
||||
let (spur_min, spur_k) = (2.0, 0.5);
|
||||
for _ in 0..32 {
|
||||
let mut changed = self.prune(spur_min, spur_k, stroke_width)?;
|
||||
changed |= self.merge_junctions()?;
|
||||
changed |= self.join_through()?;
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Nodes with a single branch end are line ends now.
|
||||
let deg = self.degrees()?;
|
||||
for (node, d) in self.nodes.iter_mut().zip(deg) {
|
||||
if d <= 1 {
|
||||
node.junction = false;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn prune(&mut self, spur_min: f64, spur_k: f64, stroke_width: f64) -> Option<bool> {
|
||||
let mut deg = self.degrees()?;
|
||||
let mut order: Vec<usize> = (0..self.branches.len()).collect();
|
||||
let lens: Vec<f64> = self.branches.iter().map(Branch::length).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
lens.get(a).copied().unwrap_or(f64::INFINITY).total_cmp(&lens.get(b).copied().unwrap_or(f64::INFINITY)).then(a.cmp(&b))
|
||||
});
|
||||
let mut keep = vec![true; self.branches.len()];
|
||||
for i in order {
|
||||
let b = self.branches.get(i)?;
|
||||
match b.ends {
|
||||
[Some(a), Some(c)] if a == c => {
|
||||
// A loop on one node: a real hole unless it lies inside the node's disc.
|
||||
let r = self.nodes.get(a).map_or(0.0, |n| n.r);
|
||||
if *lens.get(i)? < 2.0 * r + 2.0 {
|
||||
*keep.get_mut(i)? = false;
|
||||
let degree = deg.get_mut(a)?;
|
||||
*degree = degree.saturating_sub(2);
|
||||
}
|
||||
}
|
||||
[Some(a), Some(c)] => {
|
||||
let (tip, fork) = if *deg.get(a)? == 1 { (a, c) } else { (c, a) };
|
||||
if *deg.get(tip)? != 1 || *deg.get(fork)? < 3 {
|
||||
continue;
|
||||
}
|
||||
let r = self.nodes.get(fork).map_or(0.0, |n| n.r);
|
||||
let tau = spur_min.max(spur_k * (2.0 * r).min(stroke_width));
|
||||
if *lens.get(i)? < r + tau {
|
||||
*keep.get_mut(i)? = false;
|
||||
*deg.get_mut(tip)? = deg.get(tip)?.saturating_sub(1);
|
||||
*deg.get_mut(fork)? = deg.get(fork)?.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let before = self.branches.len();
|
||||
let mut k = keep.into_iter();
|
||||
self.branches.retain(|_| k.next().unwrap_or(true));
|
||||
Some(self.branches.len() != before)
|
||||
}
|
||||
|
||||
/// Direction leaving node `node` along branch `b` (unit), from the point `reach` px along it.
|
||||
fn leaving(&self, b: &Branch, node: usize, reach: f64) -> Option<Vec2> {
|
||||
let pts: Vec<Point> = if b.ends[0] == Some(node) { b.pts.clone() } else { b.pts.iter().rev().copied().collect() };
|
||||
let first = *pts.first()?;
|
||||
let mut acc = 0.0;
|
||||
let mut far = *pts.last()?;
|
||||
for w in pts.windows(2) {
|
||||
acc += w[0].distance(w[1]);
|
||||
if acc >= reach {
|
||||
far = w[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
{
|
||||
let v = far - first;
|
||||
let len = v.hypot();
|
||||
(len > 1e-12 && len.is_finite()).then(|| v / len)
|
||||
}
|
||||
}
|
||||
|
||||
/// For each node, the (branch, end) pairs that end there.
|
||||
fn ends_index(&self) -> Option<Vec<Vec<(usize, usize)>>> {
|
||||
let mut idx = vec![vec![]; self.nodes.len()];
|
||||
for (i, b) in self.branches.iter().enumerate() {
|
||||
for k in 0..2 {
|
||||
if let Some(n) = b.ends.get(k).copied().flatten() {
|
||||
idx.get_mut(n)?.push((i, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(idx)
|
||||
}
|
||||
|
||||
/// Two forks to merge into one junction: joined by a bridge shorter than their discs'
|
||||
/// radii plus 2 px (pixel quantisation), or the crossing of two lines that thinning split in
|
||||
/// two (degree 3 each, the bridge no longer than `8 r + 6`, and the other two runs at one fork
|
||||
/// continuing the other two at the other, each pair within 25° of straight on).
|
||||
/// All non-conflicting pairs are merged in one pass, shortest bridges first.
|
||||
fn merge_junctions(&mut self) -> Option<bool> {
|
||||
let idx = self.ends_index()?;
|
||||
let deg: Vec<usize> = idx.iter().map(Vec::len).collect();
|
||||
let mut cand: Vec<(f64, usize)> = vec![];
|
||||
for (i, b) in self.branches.iter().enumerate() {
|
||||
let [Some(a), Some(c)] = b.ends else { continue };
|
||||
if a != c && *deg.get(a)? >= 3 && *deg.get(c)? >= 3 {
|
||||
cand.push((b.length(), i));
|
||||
}
|
||||
}
|
||||
cand.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.cmp(&y.1)));
|
||||
let mut used = vec![false; self.nodes.len()];
|
||||
let mut merges = vec![];
|
||||
for (l, i) in cand {
|
||||
let b = self.branches.get(i)?;
|
||||
let [Some(a), Some(c)] = b.ends else { continue };
|
||||
if *used.get(a)? || *used.get(c)? {
|
||||
continue;
|
||||
}
|
||||
let (ra, rc) = (self.nodes.get(a)?.r, self.nodes.get(c)?.r);
|
||||
let mut merge = l < ra + rc + 2.0;
|
||||
if !merge && *deg.get(a)? == 3 && *deg.get(c)? == 3 && l <= 8.0 * ra.max(rc) + 6.0 {
|
||||
let reach = 3.0 * ra.max(rc) + 4.0;
|
||||
let arms = |n: usize| -> Vec<Vec2> {
|
||||
idx.get(n)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(j, _)| *j != i)
|
||||
.filter_map(|&(j, _)| self.branches.get(j))
|
||||
.filter(|o| o.ends[0] != o.ends[1])
|
||||
.filter_map(|o| self.leaving(o, n, reach))
|
||||
.collect()
|
||||
};
|
||||
let (aa, cc) = (arms(a), arms(c));
|
||||
if let ([a1, a2], [c1, c2]) = (aa.as_slice(), cc.as_slice()) {
|
||||
let straight = |u: &Vec2, v: &Vec2| u.dot(*v) < -(25f64.to_radians().cos());
|
||||
merge = (straight(a1, c1) && straight(a2, c2)) || (straight(a1, c2) && straight(a2, c1));
|
||||
}
|
||||
}
|
||||
if merge {
|
||||
*used.get_mut(a)? = true;
|
||||
*used.get_mut(c)? = true;
|
||||
merges.push((i, a, c));
|
||||
}
|
||||
}
|
||||
if merges.is_empty() {
|
||||
return Some(false);
|
||||
}
|
||||
let mut remove = vec![false; self.branches.len()];
|
||||
for &(i, a, c) in &merges {
|
||||
*remove.get_mut(i)? = true;
|
||||
let mid = self.branches.get(i)?.pts.get(self.branches.get(i)?.pts.len() / 2).copied().unwrap_or(self.nodes.get(a)?.pos);
|
||||
let r = self.nodes.get(a)?.r.max(self.nodes.get(c)?.r);
|
||||
self.nodes.get_mut(a)?.pos = mid;
|
||||
self.nodes.get_mut(a)?.r = r;
|
||||
for &(j, k) in idx.get(a)?.iter().chain(idx.get(c)?) {
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
let Some(br) = self.branches.get_mut(j) else {
|
||||
continue;
|
||||
};
|
||||
*br.ends.get_mut(k)? = Some(a);
|
||||
let last = br.pts.len().saturating_sub(1);
|
||||
if let Some(p) = br.pts.get_mut(if k == 0 { 0 } else { last }) {
|
||||
*p = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = remove.into_iter();
|
||||
self.branches.retain(|_| !k.next().unwrap_or(false));
|
||||
Some(true)
|
||||
}
|
||||
|
||||
fn join_through(&mut self) -> Option<bool> {
|
||||
let mut idx = self.ends_index()?;
|
||||
let mut slots: Vec<Option<Branch>> = std::mem::take(&mut self.branches).into_iter().map(Some).collect();
|
||||
let mut changed = false;
|
||||
for node in 0..self.nodes.len() {
|
||||
let at: Vec<(usize, usize)> = idx
|
||||
.get(node)?
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(i, k)| slots.get(i).and_then(Option::as_ref).is_some_and(|b| b.ends.get(k) == Some(&Some(node))))
|
||||
.collect();
|
||||
let &[(i, ki), (j, kj)] = at.as_slice() else { continue };
|
||||
changed = true;
|
||||
if i == j {
|
||||
if let Some(b) = slots.get_mut(i).and_then(Option::as_mut) {
|
||||
b.pts.pop();
|
||||
b.d.pop();
|
||||
b.ends = [None, None];
|
||||
b.closed = b.pts.len() >= 3;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (Some(mut a), Some(mut other)) = (slots.get_mut(i)?.take(), slots.get_mut(j)?.take()) else {
|
||||
continue;
|
||||
};
|
||||
// Orient: a ends at the node, other starts at it.
|
||||
if ki == 0 {
|
||||
a.pts.reverse();
|
||||
a.d.reverse();
|
||||
a.ends.reverse();
|
||||
}
|
||||
if kj == 1 {
|
||||
other.pts.reverse();
|
||||
other.d.reverse();
|
||||
other.ends.reverse();
|
||||
}
|
||||
a.pts.extend(other.pts.into_iter().skip(1));
|
||||
a.d.extend(other.d.into_iter().skip(1));
|
||||
a.ends[1] = other.ends[1];
|
||||
// The joined branch is slot i: re-point both far ends' index entries.
|
||||
for e in a.ends {
|
||||
if let Some(v) = e.and_then(|n| idx.get_mut(n)) {
|
||||
v.retain(|&(b, _)| b != i && b != j);
|
||||
}
|
||||
}
|
||||
for (k, e) in a.ends.iter().enumerate() {
|
||||
if let Some(v) = e.and_then(|n| idx.get_mut(n)) {
|
||||
v.push((i, k));
|
||||
}
|
||||
}
|
||||
*slots.get_mut(i)? = Some(a);
|
||||
}
|
||||
self.branches = slots.into_iter().flatten().collect();
|
||||
Some(changed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::centerline::{distance, thin};
|
||||
|
||||
fn raw_graph(arms: &[(Point, Point)], width: f64) -> (Grid, Vec<f32>) {
|
||||
let (w, h) = (180, 180);
|
||||
let mask: Vec<_> = (0..w * h)
|
||||
.map(|i| {
|
||||
let p = Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
arms.iter().any(|&(a, b)| {
|
||||
let v = b - a;
|
||||
let t = ((p - a).dot(v) / v.hypot2()).clamp(0.0, 1.0);
|
||||
p.distance(a + v * t) <= width / 2.0
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut grid = Grid::from_mask(&mask, w, h).unwrap();
|
||||
let dist = distance::squared(&grid.on, grid.w, grid.h).unwrap();
|
||||
thin::thin(&mut grid);
|
||||
(grid, dist)
|
||||
}
|
||||
|
||||
fn graph(arms: &[(Point, Point)], width: f64) -> Graph {
|
||||
let (g, dist) = raw_graph(arms, width);
|
||||
let mut graph = build(&g, &dist).unwrap();
|
||||
graph.simplify(13.0).unwrap();
|
||||
graph
|
||||
}
|
||||
|
||||
fn arms(degrees: &[f64]) -> Vec<(Point, Point)> {
|
||||
let c = Point::new(90.27, 90.41);
|
||||
degrees
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let a = a.to_radians();
|
||||
(c, c + Vec2::new(a.cos(), a.sin()) * 65.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_degrees(g: &Graph, expected: &[usize]) {
|
||||
let mut degrees: Vec<_> = g.degrees().unwrap().into_iter().filter(|&d| d > 0).collect();
|
||||
degrees.sort_unstable();
|
||||
assert_eq!(degrees, expected, "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_y_x_oblique_t_and_narrow_y_have_their_junction_degrees() {
|
||||
for width in [2.0, 4.0, 8.0] {
|
||||
for (name, angles) in [
|
||||
("T", vec![0.0, 90.0, 180.0]),
|
||||
("Y", vec![90.0, 210.0, 330.0]),
|
||||
("X", vec![45.0, 135.0, 225.0, 315.0]),
|
||||
("oblique T", vec![17.0, 107.0, 197.0]),
|
||||
("narrow Y", vec![90.0, 255.0, 285.0]),
|
||||
] {
|
||||
let g = graph(&arms(&angles), width);
|
||||
let mut want = vec![1; angles.len()];
|
||||
want.push(angles.len());
|
||||
eprintln!("{name}, width {width}");
|
||||
assert_degrees(&g, &want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossings_twenty_to_ninety_degrees_merge_into_one_four_way_junction() {
|
||||
for width in [2.0, 4.0, 8.0] {
|
||||
for angle in (20..=90).step_by(10) {
|
||||
let g = graph(&arms(&[0.0, 180.0, angle as f64, angle as f64 + 180.0]), width);
|
||||
eprintln!("crossing {angle}, width {width}");
|
||||
assert_degrees(&g, &[1, 1, 1, 1, 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_at_twenty_four_angles_have_no_spurs() {
|
||||
let c = Point::new(90.27, 90.41);
|
||||
for width in [1.0, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0] {
|
||||
for angle in 0..24 {
|
||||
let a = (angle as f64 * 15.0).to_radians();
|
||||
let v = Vec2::new(a.cos(), a.sin()) * 65.0;
|
||||
let g = graph(&[(c - v, c + v)], width);
|
||||
assert_eq!(g.branches.len(), 1, "width {width}, angle {angle}");
|
||||
assert_degrees(&g, &[1, 1]);
|
||||
assert!(g.branches[0].length() > 110.0);
|
||||
assert!(!g.branches[0].closed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_blobs_never_panic_and_build_the_same_graph_twice() {
|
||||
let mut seed = 73u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
(seed >> 33) as usize
|
||||
};
|
||||
for case in 0..200 {
|
||||
let (w, h) = (3 + rnd() % 45, 3 + rnd() % 45);
|
||||
let blobs: Vec<_> = (0..1 + rnd() % 8).map(|_| (rnd() % w, rnd() % h, 1 + rnd() % 12)).collect();
|
||||
let mask: Vec<_> =
|
||||
(0..w * h).map(|i| blobs.iter().any(|&(x, y, r)| (i % w).abs_diff(x).pow(2) + (i / w).abs_diff(y).pow(2) <= r * r)).collect();
|
||||
let mut g = Grid::from_mask(&mask, w, h).unwrap();
|
||||
let dist = distance::squared(&g.on, g.w, g.h).unwrap();
|
||||
thin::thin(&mut g);
|
||||
let mut a = build(&g, &dist).unwrap();
|
||||
let mut b = build(&g, &dist).unwrap();
|
||||
assert_eq!(a, b, "raw graph, case {case}");
|
||||
a.simplify(13.0).unwrap();
|
||||
b.simplify(13.0).unwrap();
|
||||
assert_eq!(a, b, "simplified graph, case {case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_crossings_merge_only_when_outer_arms_continue_in_pairs() {
|
||||
let a = Point::new(20.0, 30.0);
|
||||
let b = Point::new(34.0, 30.0);
|
||||
let points = [a, b, a + Vec2::new(-20.0, -6.0), a + Vec2::new(-20.0, 6.0), b + Vec2::new(20.0, -6.0), b + Vec2::new(20.0, 6.0)];
|
||||
let mut g = Graph {
|
||||
nodes: points.iter().enumerate().map(|(i, &pos)| Node { pos, r: 1.0, junction: i < 2 }).collect(),
|
||||
branches: [(0, 1), (0, 2), (0, 3), (1, 4), (1, 5)]
|
||||
.into_iter()
|
||||
.map(|(a, b)| Branch {
|
||||
pts: vec![points[a], points[a].lerp(points[b], 0.5), points[b]],
|
||||
d: vec![1.5; 3],
|
||||
ends: [Some(a), Some(b)],
|
||||
closed: false,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
// The bridge is 14 px: too long for the 4 px disc-overlap threshold.
|
||||
let mut bent = g.clone();
|
||||
bent.branches[4].pts = vec![b, b + Vec2::new(-20.0, 20.0)];
|
||||
assert_eq!(bent.merge_junctions(), Some(false));
|
||||
assert_eq!(g.merge_junctions(), Some(true));
|
||||
assert_degrees(&g, &[1, 1, 1, 1, 4]);
|
||||
assert_eq!(g.nodes[0].pos, Point::new(27.0, 30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_keeps_significant_arms_and_real_holes() {
|
||||
let centre = Point::new(20.0, 20.0);
|
||||
let mut g = Graph { nodes: vec![Node { pos: centre, r: 3.0, junction: true }], branches: vec![] };
|
||||
for (n, length) in [2.0, 8.0, 9.0].into_iter().enumerate() {
|
||||
let end = centre + Vec2::new(length, 0.0);
|
||||
g.nodes.push(Node { pos: end, r: 1.0, junction: false });
|
||||
g.branches.push(Branch { pts: vec![centre, end], d: vec![3.5, 1.5], ends: [Some(0), Some(n + 1)], closed: false });
|
||||
}
|
||||
// r + max(2, 0.5 * min(6, 13)) = 6: only the 2 px arm is a spur.
|
||||
assert_eq!(g.prune(2.0, 0.5, 13.0), Some(true));
|
||||
assert_eq!(g.branches.len(), 2);
|
||||
let mut loop_graph = Graph { nodes: vec![g.nodes[0].clone()], branches: vec![] };
|
||||
for side in [1.0, 5.0] {
|
||||
loop_graph.branches.push(Branch {
|
||||
pts: vec![centre, centre + Vec2::new(side, 0.0), centre + Vec2::new(side, side), centre],
|
||||
d: vec![3.5; 4],
|
||||
ends: [Some(0), Some(0)],
|
||||
closed: false,
|
||||
});
|
||||
}
|
||||
assert_eq!(loop_graph.prune(2.0, 0.5, 13.0), Some(true));
|
||||
assert_eq!(loop_graph.branches.len(), 1);
|
||||
assert!(loop_graph.branches[0].length() > 8.0);
|
||||
loop_graph.join_through().unwrap();
|
||||
assert!(loop_graph.branches[0].closed);
|
||||
assert_eq!(loop_graph.branches[0].ends, [None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_and_branch_limits_refuse_the_whole_graph() {
|
||||
let (g, dist) = raw_graph(&arms(&[0.0, 90.0, 180.0]), 4.0);
|
||||
assert!(build_with_limits(&g, &dist, 1, MAX_BRANCHES).is_none());
|
||||
assert!(build_with_limits(&g, &dist, MAX_NODES, 1).is_none());
|
||||
assert!(build(&g, &[]).is_none());
|
||||
let mut invalid = dist.clone();
|
||||
invalid[0] = f32::NAN;
|
||||
assert!(build(&g, &invalid).is_none());
|
||||
invalid[0] = -1.0;
|
||||
assert!(build(&g, &invalid).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Create Strokes: the parts of a colour layer no wider than the Stroke width, traced as stroked
|
||||
//! centre lines instead of filled outlines.
|
||||
//!
|
||||
//! 1. **Distance**: an exact Euclidean distance transform in each blob crop gives each pixel
|
||||
//! its distance to the nearest pixel outside the blob.
|
||||
//! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less
|
||||
//! one) is within the stroke width is a line; wider ones stay filled areas.
|
||||
//! 3. **Skeleton**: Guo–Hall thins the crop to one pixel, preserving diagonal ends, then
|
||||
//! staircase corners that don't connect anything are dropped.
|
||||
//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels
|
||||
//! placed at their deepest pixel) become polylines; rings become closed ones. Spurs are
|
||||
//! measured beyond each junction's maximal disc. Overlapping or split-crossing junctions
|
||||
//! merge, and runs join through nodes left with two ends.
|
||||
//! 5. **Ink**: the skeleton is right to a pixel only, so each of its points is moved to the middle
|
||||
//! of the line's ink, read from the image: across the line (along the pixel column or row most
|
||||
//! nearly square to it) each pixel is as much ink as its colour is the line's rather than the
|
||||
//! paper's, and the ink's centroid is the line's middle there and its sum the line's width (an
|
||||
//! anti-aliased or blurred line says both to a fraction of a pixel). The stroke width is the
|
||||
//! median of those widths, and each end is put where the ink ends, less the half width a round
|
||||
//! cap adds.
|
||||
//! 6. **Curves**: each polyline is fitted as outlines are ([`fit::fit_piece`]): a straight line
|
||||
//! is two anchors, corners stay corners, the rest is as few curves as Paths allows.
|
||||
|
||||
mod distance;
|
||||
mod graph;
|
||||
mod thin;
|
||||
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
|
||||
use crate::Raster;
|
||||
use crate::fit;
|
||||
use crate::logo::Style;
|
||||
|
||||
/// One line of a colour layer: its centre lines and the width to stroke them with.
|
||||
pub(crate) struct Line {
|
||||
pub path: PathData,
|
||||
/// Stroke width in pixels.
|
||||
pub width: f64,
|
||||
pub pixels: usize,
|
||||
/// Its ends are cut square (the ink is as long beside the line's middle as along it), not round.
|
||||
pub square: bool,
|
||||
}
|
||||
|
||||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||||
/// pixels, and which pixels they cover (to leave out of the filled areas).
|
||||
/// `ink` is the colour of the layer's ink in `img` (its palette colour; in Black and White the
|
||||
/// image's own black), which the lines' middles and widths are read against.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn lines(
|
||||
labels: &[u16],
|
||||
label: u16,
|
||||
w: usize,
|
||||
h: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
) -> (Vec<bool>, Vec<Line>) {
|
||||
let mut out = vec![];
|
||||
let Some(n) = w.checked_mul(h).filter(|&n| n > 0 && n as u64 <= crate::MAX_PIXELS && n == labels.len()) else { return (vec![], out) };
|
||||
let mut thin = vec![false; n];
|
||||
if w == 0 || h == 0 || !max_width.is_finite() || max_width <= 0.0 || !ink.iter().all(|v| v.is_finite()) {
|
||||
return (thin, out);
|
||||
}
|
||||
let mask = |i: usize| labels.get(i) == Some(&label);
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack = vec![];
|
||||
for start in 0..w * h {
|
||||
if !mask(start) || seen.get(start).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let mut comp = vec![];
|
||||
if let Some(v) = seen.get_mut(start) {
|
||||
*v = true;
|
||||
}
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
comp.push(i);
|
||||
for j in neighbours(i, w, h) {
|
||||
if mask(j) && !seen.get(j).copied().unwrap_or(false) {
|
||||
if let Some(v) = seen.get_mut(j) {
|
||||
*v = true;
|
||||
}
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(g) = Grid::from_component(&comp, w) else { continue };
|
||||
let Some(dt) = distance::squared(&g.on, g.w, g.h) else { continue };
|
||||
let widest = 2.0 * f64::from(dt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0;
|
||||
if widest > max_width {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = trace_line(g, &dt, comp.len(), max_width, img, ink, style) else { continue };
|
||||
for &i in &comp {
|
||||
if let Some(v) = thin.get_mut(i) {
|
||||
*v = true;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
(thin, out)
|
||||
}
|
||||
|
||||
/// The 8 neighbours of pixel `i` inside the `w` × `h` image.
|
||||
fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
const AROUND: [(i64, i64); 8] = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)];
|
||||
AROUND.into_iter().filter_map(move |(dx, dy)| {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
(nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| ny as usize * w + nx as usize)
|
||||
})
|
||||
}
|
||||
|
||||
/// A valid crop is at most the raster limit plus a one-pixel paper border.
|
||||
fn crop_size(w: usize, h: usize) -> Option<usize> {
|
||||
if w < 3 || h < 3 {
|
||||
return None;
|
||||
}
|
||||
let inner = (w - 2).checked_mul(h - 2)?;
|
||||
if inner as u64 > crate::MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
w.checked_mul(h).filter(|&n| n <= isize::MAX as usize)
|
||||
}
|
||||
|
||||
/// A grid around one line: its pixels with a one-pixel empty border.
|
||||
struct Grid {
|
||||
on: Vec<bool>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
/// Image position of grid pixel (0, 0).
|
||||
x0: i64,
|
||||
y0: i64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn from_component(comp: &[usize], image_width: usize) -> Option<Self> {
|
||||
if image_width == 0 {
|
||||
return None;
|
||||
}
|
||||
let xs = comp.iter().map(|&i| i % image_width);
|
||||
let ys = comp.iter().map(|&i| i / image_width);
|
||||
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
|
||||
let (w, h) = (x1.checked_sub(x0)?.checked_add(3)?, y1.checked_sub(y0)?.checked_add(3)?);
|
||||
let n = crop_size(w, h)?;
|
||||
let mut g = Self { on: vec![false; n], w, h, x0: x0 as i64 - 1, y0: y0 as i64 - 1 };
|
||||
for &i in comp {
|
||||
*g.on.get_mut((i / image_width - y0 + 1) * w + i % image_width - x0 + 1)? = true;
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn from_mask(mask: &[bool], w: usize, h: usize) -> Option<Self> {
|
||||
if w.checked_mul(h)? != mask.len() {
|
||||
return None;
|
||||
}
|
||||
let comp: Vec<_> = mask.iter().enumerate().filter_map(|(i, &on)| on.then_some(i)).collect();
|
||||
Self::from_component(&comp, w)
|
||||
}
|
||||
fn offsets(&self) -> [isize; 8] {
|
||||
let w = self.w as isize;
|
||||
[-w, -w + 1, 1, w + 1, w, w - 1, -1, -w - 1]
|
||||
}
|
||||
fn is_interior(&self, i: usize) -> bool {
|
||||
self.w > 0 && i < self.on.len() && !i.is_multiple_of(self.w) && i % self.w + 1 < self.w && i / self.w > 0 && i / self.w + 1 < self.h
|
||||
}
|
||||
/// N, NE, E, SE, S, SW, W, NW, with paper outside the interior.
|
||||
fn ring(&self, i: usize) -> [bool; 8] {
|
||||
if !self.is_interior(i) {
|
||||
return [false; 8];
|
||||
}
|
||||
self.offsets().map(|o| self.on.get(i.wrapping_add_signed(o)).copied().unwrap_or(false))
|
||||
}
|
||||
fn interior(&self) -> impl Iterator<Item = usize> + use<> {
|
||||
let (w, h) = (self.w, self.h);
|
||||
(1..h.saturating_sub(1)).flat_map(move |y| (1..w.saturating_sub(1)).map(move |x| y * w + x))
|
||||
}
|
||||
fn neighbours(&self, i: usize) -> impl Iterator<Item = usize> + '_ {
|
||||
self.offsets().into_iter().map(move |o| i.wrapping_add_signed(o)).filter(move |&j| self.is_interior(i) && self.on.get(j) == Some(&true))
|
||||
}
|
||||
fn degree(&self, i: usize) -> usize {
|
||||
self.neighbours(i).count()
|
||||
}
|
||||
/// The centre of pixel `i` in image coordinates.
|
||||
fn centre(&self, i: usize) -> Point {
|
||||
Point::new((self.x0 + (i % self.w) as i64) as f64 + 0.5, (self.y0 + (i / self.w) as i64) as f64 + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||||
fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
|
||||
thin::thin(&mut g);
|
||||
let mut graph = graph::build(&g, dist)?;
|
||||
graph.simplify(max_width)?;
|
||||
let meet = graph.degrees()?;
|
||||
let mut runs = graph.branches;
|
||||
if runs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The line's mean width: its pixels over its length (the skeleton stops about half a width
|
||||
// short of each end).
|
||||
let width = |runs: &[graph::Branch]| {
|
||||
let length: f64 = runs.iter().map(graph::Branch::length).sum();
|
||||
let px = pixels as f64;
|
||||
((-length + (length * length + 4.0 * px).sqrt()) / 2.0).clamp(1.0, max_width.max(1.0))
|
||||
};
|
||||
let coarse = width(&runs);
|
||||
let ink = Ink { img, color: ink, reach: (max_width / 2.0).ceil().clamp(1.0, 64.0) as i64 + 2 };
|
||||
let (mut widths, mut soft, mut read) = (vec![], 0usize, 0usize);
|
||||
for r in &mut runs {
|
||||
let n = r.pts.len();
|
||||
let old = r.pts.clone();
|
||||
for i in 0..n {
|
||||
// A junction stays where its pixels put it.
|
||||
let end = if i == 0 {
|
||||
r.ends[0]
|
||||
} else if i + 1 == n {
|
||||
r.ends[1]
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if end.is_some_and(|e| meet.get(e).is_some_and(|&n| n > 1)) {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = if r.closed {
|
||||
(*old.get((i + n - 2 % n) % n)?, *old.get((i + 2) % n)?)
|
||||
} else {
|
||||
(*old.get(i.saturating_sub(2))?, *old.get((i + 2).min(n - 1))?)
|
||||
};
|
||||
let d = b - a;
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let upright = d.x.abs() >= d.y.abs();
|
||||
let Some(across) = ink.across(*old.get(i)?, upright) else { continue };
|
||||
*r.pts.get_mut(i)? = *old.get(i)? + if upright { kurbo::Vec2::new(0.0, across.middle) } else { kurbo::Vec2::new(across.middle, 0.0) };
|
||||
widths.push(across.width * if upright { d.x.abs() } else { d.y.abs() } / d.hypot());
|
||||
read += 1;
|
||||
soft += usize::from(across.soft);
|
||||
}
|
||||
}
|
||||
// The stroke is as wide as the ink at the line's middle point (where no ink could be read, as
|
||||
// wide as its pixels make it).
|
||||
widths.sort_by(f64::total_cmp);
|
||||
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
|
||||
// Each end goes where the ink ends, less the half width its cap adds. The cap is square when
|
||||
// the ends wide enough to tell are, taken together: the ink reaches as far a third of the width to either side
|
||||
// of the line's middle as along it, where a round end stops short by a seventh of the width.
|
||||
let (mut short_by, mut told) = (0.0, 0);
|
||||
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
|
||||
for k in 0..2 {
|
||||
if r.ends.get(k).copied().flatten().is_some_and(|e| meet.get(e).is_some_and(|&n| n > 1)) {
|
||||
continue;
|
||||
}
|
||||
let n = r.pts.len();
|
||||
let at = |i: usize| if k == 0 { i.min(n - 1) } else { n - 1 - i.min(n - 1) };
|
||||
// The last points of a wide line's skeleton turn towards a corner of its end: the end
|
||||
// lies where the line was going a width back, and they are put on that line.
|
||||
let back = (width.round().clamp(0.0, n as f64) as usize + 2).max(3);
|
||||
let (tip, inner) = (*r.pts.get(at(0))?, *r.pts.get(at(back))?);
|
||||
// (Only an end that has left that line: over so few pixels the line's direction is known
|
||||
// to a few degrees at best, less without anti-aliasing, and an end already on it is
|
||||
// better left where it is.)
|
||||
let before = inner - *r.pts.get(at(2 * back))?;
|
||||
let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot();
|
||||
let d = if wide { before } else { tip - inner };
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let along = d / d.hypot();
|
||||
let on_line = |p: Point| inner + along * (p - inner).dot(along).max(0.0);
|
||||
if wide {
|
||||
for i in 0..back {
|
||||
let point = r.pts.get_mut(at(i))?;
|
||||
*point = on_line(*point);
|
||||
}
|
||||
}
|
||||
let tip = on_line(tip);
|
||||
let reach = ink.beyond(tip, along);
|
||||
if width >= 3.0 {
|
||||
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
|
||||
let short = reach - (ink.beyond(tip + aside, along) + ink.beyond(tip - aside, along)) / 2.0;
|
||||
short_by += short / width;
|
||||
told += 1;
|
||||
}
|
||||
let more = reach - width / 2.0;
|
||||
if more.abs() > 0.05 && more > 1.0 - (tip - inner).hypot() {
|
||||
*r.pts.get_mut(at(0))? = tip + along * more;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut style = *style;
|
||||
if read > 0 && soft * 4 < read {
|
||||
// Hard pixels: the middle is only known to about half a pixel (as for outlines).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 2.5;
|
||||
style.sigma = 1.5;
|
||||
}
|
||||
let subs: Vec<_> = runs
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if r.closed { fit::subpath(&fit::fit_closed(&r.pts, &style)?, true) } else { fit::subpath(&fit::fit_piece(&r.pts, &style, false), false) }
|
||||
})
|
||||
.collect();
|
||||
if subs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line { path: PathData::new(subs), width, pixels, square: told > 0 && short_by / f64::from(told) < 0.07 })
|
||||
}
|
||||
|
||||
/// What the image says of a line's ink.
|
||||
struct Ink<'a> {
|
||||
img: &'a Raster,
|
||||
/// The line's colour.
|
||||
color: [f64; 3],
|
||||
/// How far (pixels) from the skeleton the ink is looked for.
|
||||
reach: i64,
|
||||
}
|
||||
|
||||
/// The ink across a line at one point: where its middle lies from the point, how wide it is, and
|
||||
/// whether its edges are soft (some pixel is part ink, part paper).
|
||||
struct Across {
|
||||
middle: f64,
|
||||
width: f64,
|
||||
soft: bool,
|
||||
}
|
||||
|
||||
impl Ink<'_> {
|
||||
/// How far the colour of pixel `(x, y)` is from the line's (`None` off the image).
|
||||
fn far(&self, x: i64, y: i64) -> Option<f64> {
|
||||
let (w, h) = (i64::from(self.img.width), i64::from(self.img.height));
|
||||
if x < 0 || y < 0 || x >= w || y >= h {
|
||||
return None;
|
||||
}
|
||||
let at = (y as usize).checked_mul(w as usize)?.checked_add(x as usize)?.checked_mul(4)?;
|
||||
let p = self.img.rgba.get(at..at.checked_add(3)?)?;
|
||||
Some(p.iter().zip(self.color).map(|(&v, c)| (f64::from(v) - c).powi(2)).sum::<f64>().sqrt())
|
||||
}
|
||||
/// How far the paper's colour is from the line's around pixel `(x, y)`: the farthest colour
|
||||
/// within reach, along its row and its column.
|
||||
fn paper(&self, x: i64, y: i64) -> f64 {
|
||||
(-self.reach..=self.reach).flat_map(|k| [self.far(x + k, y), self.far(x, y + k)]).flatten().fold(0.0, f64::max)
|
||||
}
|
||||
/// The ink across the line through the pixel `p` lies in: along its column (`upright`) or row.
|
||||
fn across(&self, p: Point, upright: bool) -> Option<Across> {
|
||||
if !p.x.is_finite() || !p.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
|
||||
let paper = self.paper(x, y);
|
||||
// Paper too like the ink says nothing.
|
||||
if paper < 24.0 {
|
||||
return None;
|
||||
}
|
||||
let tone = |k: i64| (if upright { self.far(x, y + k) } else { self.far(x + k, y) }).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
// From the pixel with most ink near the skeleton's (thinning leaves it up to a pixel or two
|
||||
// off the line's darkest row) out to where the ink ends, or rises again (another line).
|
||||
let peak = [0, -1, 1, -2, 2].into_iter().fold(0, |best, k| if tone(k) > tone(best) + 1e-9 { k } else { best });
|
||||
let here = tone(peak);
|
||||
let (mut sum, mut moment, mut soft) = (here, here * peak as f64, here > 0.1 && here < 0.9);
|
||||
for side in [-1, 1] {
|
||||
let mut last = here;
|
||||
for k in 1..=self.reach {
|
||||
let t = tone(peak + side * k);
|
||||
if t < 0.04 || t > last + 0.1 {
|
||||
break;
|
||||
}
|
||||
sum += t;
|
||||
moment += t * (peak + side * k) as f64;
|
||||
soft |= t > 0.1 && t < 0.9;
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
// The pixel's centre is at `p`'s own half-pixel; the middle is measured from `p`.
|
||||
let centre = if upright { y as f64 + 0.5 - p.y } else { x as f64 + 0.5 - p.x };
|
||||
(sum > 0.0).then(|| Across { middle: centre + moment / sum, width: sum, soft })
|
||||
}
|
||||
/// How much ink (pixels) lies beyond `tip`, going `dir` (a unit vector).
|
||||
fn beyond(&self, tip: Point, dir: kurbo::Vec2) -> f64 {
|
||||
if !tip.x.is_finite() || !tip.y.is_finite() || !dir.x.is_finite() || !dir.y.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let paper = self.paper(tip.x.floor() as i64, tip.y.floor() as i64);
|
||||
if paper < 24.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for step in 1..=4 * (self.reach + 2) {
|
||||
let q = tip + dir * (step as f64 * 0.25);
|
||||
let t = self.far(q.x.floor() as i64, q.y.floor() as i64).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
if t < 0.04 {
|
||||
break;
|
||||
}
|
||||
sum += 0.25 * t;
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Guo–Hall A1 thinning (Guo & Hall 1989, doi:10.1145/62065.62074).
|
||||
//! Neighbourhoods are judged together in each sub-iteration; only changed ones are revisited.
|
||||
|
||||
use super::Grid;
|
||||
|
||||
/// Guo–Hall A1's test for pixel with neighbours `p` (P2..P9) in sub-iteration `step`.
|
||||
fn guo_hall_removable(p: &[bool; 8], step: usize) -> bool {
|
||||
let [p2, p3, p4, p5, p6, p7, p8, p9] = *p;
|
||||
let c = usize::from(!p2 && (p3 || p4)) + usize::from(!p4 && (p5 || p6)) + usize::from(!p6 && (p7 || p8)) + usize::from(!p8 && (p9 || p2));
|
||||
let n1 = usize::from(p9 || p2) + usize::from(p3 || p4) + usize::from(p5 || p6) + usize::from(p7 || p8);
|
||||
let n2 = usize::from(p2 || p3) + usize::from(p4 || p5) + usize::from(p6 || p7) + usize::from(p8 || p9);
|
||||
let m = if step == 0 { (p6 || p7 || !p9) && p8 } else { (p2 || p3 || !p5) && p4 };
|
||||
c == 1 && (2..=3).contains(&n1.min(n2)) && !m
|
||||
}
|
||||
|
||||
/// The Guo–Hall rounds alone (no staircase removal). Bounded: at most `w + h` rounds.
|
||||
///
|
||||
/// Only pixels whose neighbourhood changed since they were last judged in a sub-iteration are
|
||||
/// judged again (a pixel's removability depends on its 8 neighbours only): in sub-iteration 0,
|
||||
/// neighbours of every pixel removed in the last round; in sub-iteration 1, neighbours of those
|
||||
/// removed in the last round's sub-iteration 1 and this round's sub-iteration 0. The result is
|
||||
/// the plain algorithm's (tested against it below).
|
||||
fn guo_hall_rounds(g: &mut Grid) {
|
||||
let n = g.on.len();
|
||||
let ring = g.offsets();
|
||||
let mut mark = vec![0u32; n];
|
||||
let mut stamp = 0u32;
|
||||
let mut around = |g: &Grid, removed: &[&[usize]]| -> Vec<usize> {
|
||||
// Two stamps per round; the crop cap keeps this well below u32::MAX.
|
||||
stamp += 1;
|
||||
let mut out = vec![];
|
||||
for &t in removed.iter().flat_map(|r| r.iter()) {
|
||||
for o in ring {
|
||||
let i = t.wrapping_add_signed(o);
|
||||
if i < n && g.on.get(i) == Some(&true) && g.is_interior(i) && mark.get(i) != Some(&stamp) {
|
||||
if let Some(m) = mark.get_mut(i) {
|
||||
*m = stamp;
|
||||
}
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
};
|
||||
let (mut prev0, mut prev1): (Vec<usize>, Vec<usize>) = (vec![], vec![]);
|
||||
for round in 0..g.w + g.h {
|
||||
let cand0: Vec<usize> =
|
||||
if round == 0 { (0..n).filter(|&i| g.on.get(i) == Some(&true) && g.is_interior(i)).collect() } else { around(g, &[&prev0, &prev1]) };
|
||||
let rem0: Vec<usize> = cand0.into_iter().filter(|&i| guo_hall_removable(&g.ring(i), 0)).collect();
|
||||
for &i in &rem0 {
|
||||
if let Some(on) = g.on.get_mut(i) {
|
||||
*on = false;
|
||||
}
|
||||
}
|
||||
let cand1: Vec<usize> =
|
||||
if round == 0 { (0..n).filter(|&i| g.on.get(i) == Some(&true) && g.is_interior(i)).collect() } else { around(g, &[&prev1, &rem0]) };
|
||||
let rem1: Vec<usize> = cand1.into_iter().filter(|&i| guo_hall_removable(&g.ring(i), 1)).collect();
|
||||
for &i in &rem1 {
|
||||
if let Some(on) = g.on.get_mut(i) {
|
||||
*on = false;
|
||||
}
|
||||
}
|
||||
if rem0.is_empty() && rem1.is_empty() {
|
||||
break;
|
||||
}
|
||||
(prev0, prev1) = (rem0, rem1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thins `g` in place (Guo–Hall, then staircase removal); see the module docs.
|
||||
pub(super) fn thin(g: &mut Grid) {
|
||||
guo_hall_rounds(g);
|
||||
staircase(g);
|
||||
}
|
||||
|
||||
/// Removes staircase pixels (see the module docs). Sequential, in scan order, until stable
|
||||
/// (at most 4 passes).
|
||||
fn staircase(g: &mut Grid) {
|
||||
for _ in 0..4 {
|
||||
let mut changed = false;
|
||||
for i in 0..g.on.len() {
|
||||
// Three or more neighbours: a corner of a staircase always has them, while a pixel
|
||||
// with two touching neighbours may be the end of a line two pixels thick (removing
|
||||
// it would eat that line from its end, pixel by pixel).
|
||||
if g.on.get(i) != Some(&true) || g.degree(i) < 3 {
|
||||
continue;
|
||||
}
|
||||
let p = g.ring(i);
|
||||
// Yokoi's x1..x8 = E, NE, N, NW, W, SW, S, SE.
|
||||
let x = [p[2], p[1], p[0], p[7], p[6], p[5], p[4], p[3]];
|
||||
let off = |k: usize| !x.get(k % 8).copied().unwrap_or(false);
|
||||
let c8: usize = [0usize, 2, 4, 6].iter().map(|&k| usize::from(off(k)) - usize::from(off(k) && off(k + 1) && off(k + 2))).sum();
|
||||
if c8 == 1 {
|
||||
if let Some(on) = g.on.get_mut(i) {
|
||||
*on = false;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn skel(w: usize, h: usize, f: impl Fn(usize, usize) -> bool) -> Grid {
|
||||
let m: Vec<bool> = (0..w * h).map(|i| f(i % w, i / w)).collect();
|
||||
let mut g = Grid::from_mask(&m, w, h).unwrap();
|
||||
thin(&mut g);
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_thin_to_lines() {
|
||||
for thick in 1..7 {
|
||||
let g = skel(40, 12, |x, y| (3..37).contains(&x) && (3..3 + thick).contains(&y));
|
||||
let on: Vec<usize> = (0..g.on.len()).filter(|&i| g.on[i]).collect();
|
||||
assert!(on.len() >= 20, "thick {thick}: {} pixels", on.len());
|
||||
// Corner spurs may remain at the square ends (the graph prunes them).
|
||||
assert!(on.iter().filter(|&&i| g.degree(i) == 1).count() >= 2, "thick {thick}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagonal_lines_two_to_five_pixels_thick_keep_their_length() {
|
||||
for thick in 2..6usize {
|
||||
for mirror in [false, true] {
|
||||
let g = skel(60, 60, |x, y| {
|
||||
let x = if mirror { 59 - x } else { x };
|
||||
(5..55).contains(&y) && x + 1 >= y && x < y + thick
|
||||
});
|
||||
let on: Vec<usize> = (0..g.on.len()).filter(|&i| g.on[i]).collect();
|
||||
let (lo, hi) = (on.iter().map(|&i| i / g.w).min().unwrap(), on.iter().map(|&i| i / g.w).max().unwrap());
|
||||
assert!(hi - lo >= 46, "thick {thick}, mirror {mirror}: shortened to {} rows", hi - lo + 1);
|
||||
assert!(on.iter().all(|&i| g.degree(i) <= 2), "thick {thick}: not one pixel wide");
|
||||
assert_eq!(on.iter().filter(|&&i| g.degree(i) == 1).count(), 2);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut stack = vec![on[0]];
|
||||
while let Some(i) = stack.pop() {
|
||||
if seen.insert(i) {
|
||||
stack.extend(g.neighbours(i));
|
||||
}
|
||||
}
|
||||
assert_eq!(seen.len(), on.len(), "disconnected diagonal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain Guo–Hall: every pixel judged every sub-iteration.
|
||||
fn reference(g: &mut Grid) {
|
||||
loop {
|
||||
let mut any = false;
|
||||
for step in 0..2 {
|
||||
let picked: Vec<usize> =
|
||||
(0..g.on.len()).filter(|&i| g.on.get(i) == Some(&true) && g.is_interior(i) && guo_hall_removable(&g.ring(i), step)).collect();
|
||||
any |= !picked.is_empty();
|
||||
for i in picked {
|
||||
if let Some(on) = g.on.get_mut(i) {
|
||||
*on = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_active_set_gives_the_plain_algorithms_result() {
|
||||
let mut seed = 11u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(seed >> 33) as usize
|
||||
};
|
||||
for case in 0..200 {
|
||||
let (w, h) = (3 + rnd() % 40, 3 + rnd() % 40);
|
||||
let blobs: Vec<(usize, usize, usize)> = (0..1 + rnd() % 6).map(|_| (rnd() % w, rnd() % h, 1 + rnd() % 9)).collect();
|
||||
let m: Vec<bool> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
blobs.iter().any(|&(bx, by, r)| (x - bx as i64).pow(2) + (y - by as i64).pow(2) <= (r * r) as i64)
|
||||
})
|
||||
.collect();
|
||||
let (mut a, mut b) = (Grid::from_mask(&m, w, h).unwrap(), Grid::from_mask(&m, w, h).unwrap());
|
||||
guo_hall_rounds(&mut a);
|
||||
reference(&mut b);
|
||||
assert!(a.on == b.on, "case {case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-7
@@ -123,10 +123,15 @@ pub struct Raster {
|
||||
}
|
||||
|
||||
impl Raster {
|
||||
/// A raster from RGBA bytes (`width * height * 4` of them).
|
||||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Self {
|
||||
assert_eq!(rgba.len(), width as usize * height as usize * 4, "rgba length must be width*height*4");
|
||||
Self { width, height, rgba }
|
||||
/// A raster from RGBA bytes, which must be exactly `width * height * 4` of them: any other
|
||||
/// length (bytes from a file, a plug-in or a message) is a [`TraceError::Decode`] error, not
|
||||
/// a crash. (That error's text is already translated; its detail names the sizes.)
|
||||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<Self, TraceError> {
|
||||
let want = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
|
||||
if want != Some(rgba.len()) {
|
||||
return Err(TraceError::Decode(format!("{} bytes of RGBA data for a {width} × {height} image", rgba.len())));
|
||||
}
|
||||
Ok(Self { width, height, rgba })
|
||||
}
|
||||
/// A raster whose pixel `(x, y)` is `f(x, y)`.
|
||||
pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self {
|
||||
@@ -192,9 +197,17 @@ impl Raster {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose
|
||||
/// fields were set to a size its bytes don't have).
|
||||
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
|
||||
if x >= self.width || y >= self.height {
|
||||
return [0; 4];
|
||||
}
|
||||
let i = (y as usize * self.width as usize + x as usize) * 4;
|
||||
[self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
|
||||
match self.rgba.get(i..i + 4) {
|
||||
Some(&[r, g, b, a]) => [r, g, b, a],
|
||||
_ => [0; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +562,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
}
|
||||
rest
|
||||
});
|
||||
let mut q = quantize(without.as_ref().unwrap_or(img), params);
|
||||
let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params);
|
||||
drop(without);
|
||||
// Each gradient is a palette entry of its own (its colour: the gradient's middle).
|
||||
let mut gradients: Vec<Option<TracedGradient>> = vec![None; q.palette.len()];
|
||||
@@ -565,7 +578,9 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
|
||||
gradients.extend(found.into_iter().map(Some));
|
||||
}
|
||||
drop(grays);
|
||||
denoise(&mut q.labels, w, h, params.noise as usize);
|
||||
// Noise clears specks, not the regions of a rare colour Automatic kept on purpose.
|
||||
let keep: Vec<bool> = q.palette.iter().map(|c| rare.contains(c)).collect();
|
||||
quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep);
|
||||
// Pixel count per palette entry, and the box its pixels lie in.
|
||||
let mut counts = vec![0usize; q.palette.len()];
|
||||
let mut boxes: Vec<Option<(usize, usize, usize, usize)>> = vec![None; q.palette.len()];
|
||||
|
||||
@@ -57,7 +57,7 @@ fn render(w: u32, h: u32, blur: f64, shade: impl Fn(f64, f64) -> Option<[u8; 3]>
|
||||
let un = |v: f64| if a > 1e-9 { ((v / a) * 255.0).round().clamp(0.0, 255.0) as u8 } else { 0 };
|
||||
rgba.extend_from_slice(&[un(p[0]), un(p[1]), un(p[2]), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
|
||||
}
|
||||
Raster::new(w, h, rgba)
|
||||
Raster::new(w, h, rgba).unwrap()
|
||||
}
|
||||
|
||||
const RED: [u8; 3] = [210, 40, 50];
|
||||
@@ -235,12 +235,12 @@ fn assert_sane(res: &TraceResult, w: u32, h: u32) {
|
||||
fn degenerate_images_are_fine() {
|
||||
let p = logo_params();
|
||||
for (w, h, img) in [
|
||||
(0, 0, Raster::new(0, 0, vec![])),
|
||||
(1, 1, Raster::new(1, 1, vec![200, 30, 30, 255])),
|
||||
(3, 3, Raster::new(3, 3, vec![0; 36])),
|
||||
(2, 2, Raster::new(2, 2, vec![255; 16])),
|
||||
(5, 1, Raster::new(5, 1, vec![9; 20])),
|
||||
(1, 7, Raster::new(1, 7, vec![130; 28])),
|
||||
(0, 0, Raster::new(0, 0, vec![]).unwrap()),
|
||||
(1, 1, Raster::new(1, 1, vec![200, 30, 30, 255]).unwrap()),
|
||||
(3, 3, Raster::new(3, 3, vec![0; 36]).unwrap()),
|
||||
(2, 2, Raster::new(2, 2, vec![255; 16]).unwrap()),
|
||||
(5, 1, Raster::new(5, 1, vec![9; 20]).unwrap()),
|
||||
(1, 7, Raster::new(1, 7, vec![130; 28]).unwrap()),
|
||||
] {
|
||||
match trace_within(&img, &p, 1000) {
|
||||
Ok(res) => assert_sane(&res, w, h),
|
||||
@@ -398,7 +398,7 @@ fn random_small_images_and_settings_never_panic() {
|
||||
});
|
||||
}
|
||||
}
|
||||
let img = Raster::new(w, h, rgba);
|
||||
let img = Raster::new(w, h, rgba).unwrap();
|
||||
let p = TraceParams {
|
||||
paths: weird[(next() % 8) as usize],
|
||||
corners: weird[(next() % 8) as usize],
|
||||
|
||||
@@ -21,6 +21,12 @@ pub(crate) fn luma(p: [u8; 4]) -> u8 {
|
||||
|
||||
/// Reduce `img` to a palette according to `params.mode`.
|
||||
pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
|
||||
quantize_keeping(img, params).0
|
||||
}
|
||||
|
||||
/// [`quantize`], and the palette colours that are rare but kept on purpose (see [`rare_colours`]):
|
||||
/// noise removal must not erase their regions.
|
||||
pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized, Vec<[u8; 3]>) {
|
||||
let px = img.rgba.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]);
|
||||
match params.mode {
|
||||
Mode::BlackAndWhite => {
|
||||
@@ -36,7 +42,7 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Quantized { labels, palette: vec![[255, 255, 255], [0, 0, 0]] }
|
||||
(Quantized { labels, palette: vec![[255, 255, 255], [0, 0, 0]] }, vec![])
|
||||
}
|
||||
Mode::Grayscale => {
|
||||
let own = own_colours(img);
|
||||
@@ -61,7 +67,7 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
|
||||
let g = l.round().clamp(0.0, 255.0) as u8;
|
||||
[g, g, g]
|
||||
});
|
||||
settled(merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize), img, &own)
|
||||
(settled(merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize, &[]), img, &own), vec![])
|
||||
}
|
||||
// Logo mode traces without quantising; asked directly, it quantises like Color.
|
||||
Mode::Color | Mode::Logo => {
|
||||
@@ -84,10 +90,24 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
|
||||
let (every, calm) = (used(&every), used(&calm));
|
||||
let count = |u: &[Used]| u.iter().map(|b| b.2).sum::<f64>();
|
||||
let from = if palette_from_own(count(&calm), count(&every)) { &calm } else { &every };
|
||||
// Automatic on flat art: colours too rare for its count, but distinct and drawn as
|
||||
// regions of their own, are added (see `rare_colours`).
|
||||
let detail = (params.color_detail / 100.0).clamp(0.0, 1.0);
|
||||
let flat_auto = params.palette == Palette::Automatic && flat_colors(from, detail).is_some();
|
||||
let mut rare: Vec<[u8; 3]> = vec![];
|
||||
// The picture with `colors` colours asked for; fewer come back when it has fewer, or
|
||||
// when noise made several of one (see `merge_mixed`).
|
||||
let reduce = |colors: u32| {
|
||||
let centers = color_palette(from, &TraceParams { colors, ..params.clone() });
|
||||
let mut reduce = |colors: u32| {
|
||||
let mut centers = color_palette(from, &TraceParams { colors, ..params.clone() });
|
||||
if flat_auto {
|
||||
let found = rare_colours(img, &own, &hk, ¢ers);
|
||||
if !found.is_empty() {
|
||||
let common = centers.len();
|
||||
centers.extend(found);
|
||||
centers = refine_around(from, centers, common);
|
||||
rare = centers.iter().skip(common).map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect();
|
||||
}
|
||||
}
|
||||
let mut lut = vec![0u16; 1 << 15];
|
||||
let center_labs = labs(¢ers);
|
||||
for (i, c, _) in &every {
|
||||
@@ -101,11 +121,13 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
|
||||
if params.palette == Palette::DocumentLibrary && !params.swatches.is_empty() {
|
||||
q
|
||||
} else {
|
||||
merge_mixed(q, img.width as usize, img.height as usize)
|
||||
merge_mixed(q, img.width as usize, img.height as usize, &rare)
|
||||
}
|
||||
};
|
||||
let q = reduce(params.colors);
|
||||
settled(q, img, &own)
|
||||
let q = settled(q, img, &own);
|
||||
rare.retain(|c| q.palette.contains(c));
|
||||
(q, rare)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +192,12 @@ fn oklab(c: [f64; 3]) -> [f64; 3] {
|
||||
]
|
||||
}
|
||||
|
||||
/// [`oklab`] of an sRGB colour, for the tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tests_oklab(c: [u8; 3]) -> [f64; 3] {
|
||||
oklab(c.map(f64::from))
|
||||
}
|
||||
|
||||
/// `q` with the colours that noise made of one colour merged, largest mix first.
|
||||
///
|
||||
/// Asked for more colours than a picture has, k-means splits a flat colour along its noise, and
|
||||
@@ -178,9 +206,11 @@ fn oklab(c: [f64; 3]) -> [f64; 3] {
|
||||
/// taken in RGB) and thoroughly mixed: the pixels of the lesser one have, on average, more than
|
||||
/// half a neighbour of the other each; or they are closer still (under 12 levels) and mixed in
|
||||
/// patches (compression blocks). The bands of a gradient are close too, but each keeps to itself
|
||||
/// and they stay apart.
|
||||
fn merge_mixed(q: Quantized, w: usize, h: usize) -> Quantized {
|
||||
/// and they stay apart. The colours in `keep` merge with none.
|
||||
fn merge_mixed(q: Quantized, w: usize, h: usize, keep: &[[u8; 3]]) -> Quantized {
|
||||
let k = q.palette.len();
|
||||
// A rare colour kept on purpose (see `rare_colours`) is distinct by choice: it merges with none.
|
||||
let kept: Vec<bool> = q.palette.iter().map(|c| keep.contains(c)).collect();
|
||||
if k < 2 || q.labels.len() != w * h {
|
||||
return q;
|
||||
}
|
||||
@@ -205,7 +235,7 @@ fn merge_mixed(q: Quantized, w: usize, h: usize) -> Quantized {
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
for a in 0..k {
|
||||
for b in a + 1..k {
|
||||
if into[a] != a || into[b] != b || count[a] <= 0.0 || count[b] <= 0.0 {
|
||||
if into[a] != a || into[b] != b || count[a] <= 0.0 || count[b] <= 0.0 || kept[a] || kept[b] {
|
||||
continue;
|
||||
}
|
||||
let mixed = touch[a * k + b] / count[a].min(count[b]);
|
||||
@@ -687,10 +717,226 @@ fn refine(used: &[Used], mut centers: Vec<[f64; 3]>) -> Vec<[f64; 3]> {
|
||||
centers
|
||||
}
|
||||
|
||||
/// Automatic: the most rare colours added to a flat picture's count.
|
||||
const RARE_BUDGET: usize = 6;
|
||||
|
||||
/// How far apart (Oklab × 100) a rare colour must be from every palette colour and from the main
|
||||
/// colour each stands for: a little more than the least difference one sees (about 2).
|
||||
const RARE_APART: f64 = 2.5;
|
||||
|
||||
/// The fewest pixels of a 4-connected region that make a rare colour a colour of the picture (a
|
||||
/// stem one pixel wide and eight long): scattered noise makes none so large.
|
||||
const RARE_REGION: usize = 8;
|
||||
|
||||
/// A rare colour drawn as several small regions (highlights) is a colour of the picture too when
|
||||
/// at least [`RARE_ISLANDS`] of its regions have this many pixels; noise kept by Noise removal is
|
||||
/// no smaller than this either.
|
||||
pub(crate) const RARE_ISLAND: usize = 4;
|
||||
|
||||
/// How many regions of [`RARE_ISLAND`] pixels make a rare colour coherent.
|
||||
const RARE_ISLANDS: usize = 3;
|
||||
|
||||
/// Automatic on flat art: the colours too rare to count among its colours (the count keeps colours
|
||||
/// on at least about 1% of the picture), or too close to a large one to get a box of their own,
|
||||
/// that are still colours of the picture: a highlight, a stem, a thin outline. A rare colour is
|
||||
/// kept when it is distinct, at least [`RARE_APART`] in Oklab (Ottosson 2020) from every colour of
|
||||
/// `centers` and from the main colour each stands for; flat, its pixels mostly of one histogram
|
||||
/// bin; and drawn, some 4-connected region of its own (not blended) pixels of at least
|
||||
/// [`RARE_REGION`] pixels, or [`RARE_ISLANDS`] regions of at least [`RARE_ISLAND`]. At most [`RARE_BUDGET`] are kept, those with the largest regions first.
|
||||
///
|
||||
/// The palette is otherwise median cut (Heckbert 1982) refined by k-means (Lloyd 1982); keeping
|
||||
/// salient colours that are rare by area follows the idea of salient-colour preservation in
|
||||
/// palette reduction (Fukuda et al. 2019, doi:10.1117/12.2521607). The rule and its numbers are
|
||||
/// VectorCraft's own.
|
||||
fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]]) -> Vec<[f64; 3]> {
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
if centers.is_empty() || own.len() != px.len() {
|
||||
return vec![];
|
||||
}
|
||||
// The picture's own (not blended) pixels by histogram bin.
|
||||
let mut bins = vec![Bin::default(); 1 << 15];
|
||||
for (p, _) in px.iter().zip(own).filter(|(p, f)| **f && p[3] >= 128) {
|
||||
bins[hk.key(p[0], p[1], p[2])].add(p);
|
||||
}
|
||||
let used: Vec<(usize, [f64; 3], f64)> = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
|
||||
let used_labs = labs(&used.iter().map(|u| u.1).collect::<Vec<_>>());
|
||||
let center_labs = labs(centers);
|
||||
// Each palette colour's main colour: its heaviest bin.
|
||||
let mut peak: Vec<Option<(f64, [f64; 3])>> = vec![None; centers.len()];
|
||||
for ((_, _, n), lab) in used.iter().zip(&used_labs) {
|
||||
let j = nearest_lab(¢er_labs, *lab);
|
||||
if let Some(slot) = peak.get_mut(j)
|
||||
&& slot.is_none_or(|(m, _)| *n > m)
|
||||
{
|
||||
*slot = Some((*n, *lab));
|
||||
}
|
||||
}
|
||||
let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>().sqrt();
|
||||
let apart = |lab: [f64; 3]| center_labs.iter().chain(peak.iter().flatten().map(|(_, l)| l)).all(|c| dist(lab, *c) >= RARE_APART);
|
||||
// The bins far from every colour, heaviest first, grouped: a bin joins the first group whose
|
||||
// heaviest bin is nearer than RARE_APART.
|
||||
let mut far: Vec<usize> = (0..used.len()).filter(|&u| used_labs.get(u).is_some_and(|l| apart(*l))).collect();
|
||||
far.sort_by(|&a, &b| used[b].2.total_cmp(&used[a].2).then(a.cmp(&b)));
|
||||
let mut group_of = vec![u16::MAX; 1 << 15];
|
||||
let mut groups: Vec<Vec<usize>> = vec![];
|
||||
for u in far {
|
||||
let (Some(lab), Some(&(bin, ..))) = (used_labs.get(u), used.get(u)) else { continue };
|
||||
let at = groups.iter().position(|g| g.first().and_then(|&f| used_labs.get(f)).is_some_and(|l| dist(*l, *lab) < RARE_APART));
|
||||
let g = match at {
|
||||
Some(g) => g,
|
||||
None if groups.len() < 64 => {
|
||||
groups.push(vec![]);
|
||||
groups.len() - 1
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
if let Some(members) = groups.get_mut(g) {
|
||||
members.push(u);
|
||||
}
|
||||
group_of[bin] = g as u16;
|
||||
}
|
||||
if groups.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// The largest 4-connected region of each group's own pixels.
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let group_at = |i: usize| -> Option<u16> {
|
||||
let p = px.get(i)?;
|
||||
(own.get(i).copied().unwrap_or(false) && p[3] >= 128).then(|| group_of[hk.key(p[0], p[1], p[2])]).filter(|&g| g != u16::MAX)
|
||||
};
|
||||
let mut largest = vec![0usize; groups.len()];
|
||||
let mut islands = vec![0usize; groups.len()];
|
||||
// Pixels of a group whose four neighbours are of it too: an edge's blend is a line one pixel
|
||||
// wide and has next to none.
|
||||
let mut inner = vec![0usize; groups.len()];
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack = vec![];
|
||||
for start in 0..w * h {
|
||||
let Some(g) = group_at(start).filter(|_| !seen[start]) else { continue };
|
||||
seen[start] = true;
|
||||
stack.push(start);
|
||||
let mut size = 0;
|
||||
while let Some(i) = stack.pop() {
|
||||
size += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
let mut alike = 0;
|
||||
for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() {
|
||||
if group_at(j) == Some(g) {
|
||||
alike += 1;
|
||||
if !seen[j] {
|
||||
seen[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
if alike == 4
|
||||
&& let Some(n) = inner.get_mut(g as usize)
|
||||
{
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
if let Some(l) = largest.get_mut(g as usize) {
|
||||
*l = (*l).max(size);
|
||||
}
|
||||
if let Some(n) = islands.get_mut(g as usize).filter(|_| size >= RARE_ISLAND) {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
// Coherent and flat groups, largest region first.
|
||||
let mut chosen: Vec<(usize, f64, [f64; 3])> = vec![];
|
||||
for (g, members) in groups.iter().enumerate() {
|
||||
let weight: f64 = members.iter().filter_map(|&u| used.get(u)).map(|u| u.2).sum();
|
||||
let heaviest = members.first().and_then(|&u| used.get(u)).map_or(0.0, |u| u.2);
|
||||
let region = largest.get(g).copied().unwrap_or(0);
|
||||
let coherent = region >= RARE_REGION || islands.get(g).is_some_and(|&n| n >= RARE_ISLANDS);
|
||||
if !coherent || heaviest < 0.5 * weight || weight <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut mean = [0.0; 3];
|
||||
for u in members.iter().filter_map(|&u| used.get(u)) {
|
||||
for (m, c) in mean.iter_mut().zip(u.1) {
|
||||
*m += c * u.2 / weight;
|
||||
}
|
||||
}
|
||||
// Not an edge's blend of two colours that the blend test let through (a thin ring of
|
||||
// anti-aliasing between two bands is coherent too): a colour that lies between two others
|
||||
// is kept only with an inside, pixels whose neighbours are all of it.
|
||||
if is_mix(mean, centers) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND) {
|
||||
continue;
|
||||
}
|
||||
chosen.push((region, weight, mean));
|
||||
}
|
||||
chosen.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.total_cmp(&a.1)).then(a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)));
|
||||
let mut out: Vec<[f64; 3]> = vec![];
|
||||
for (_, _, c) in chosen {
|
||||
if out.len() >= RARE_BUDGET {
|
||||
break;
|
||||
}
|
||||
let lab = oklab(c);
|
||||
if out.iter().all(|o| dist(oklab(*o), lab) >= RARE_APART) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `c` (sRGB) lies between two of `centers`, as anti-aliasing mixes them: within 12 levels
|
||||
/// of the line between them, and at least an eighth of the way from each.
|
||||
fn is_mix(c: [f64; 3], centers: &[[f64; 3]]) -> bool {
|
||||
centers.iter().enumerate().any(|(i, a)| {
|
||||
centers.iter().skip(i + 1).any(|b| {
|
||||
let d = [0, 1, 2].map(|k| b[k] - a[k]);
|
||||
let len2: f64 = d.iter().map(|v| v * v).sum();
|
||||
if len2 <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let t = (0..3).map(|k| (c[k] - a[k]) * d[k]).sum::<f64>() / len2;
|
||||
let off2: f64 = (0..3).map(|k| (c[k] - a[k] - t * d[k]).powi(2)).sum();
|
||||
(0.12..=0.88).contains(&t) && off2 < 144.0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// `centers` refined over the bins `used` as k-means does, but the colours from `fixed` on (rare
|
||||
/// colours, see [`rare_colours`]) stay where they are: they take their own bins from the colours
|
||||
/// they were averaged into, and those move back to their own.
|
||||
fn refine_around(used: &[Used], mut centers: Vec<[f64; 3]>, fixed: usize) -> Vec<[f64; 3]> {
|
||||
let bins = labs(&used.iter().map(|u| u.1).collect::<Vec<_>>());
|
||||
for _ in 0..4 {
|
||||
let center_labs = labs(¢ers);
|
||||
let mut sum = vec![[0.0; 3]; centers.len()];
|
||||
let mut cnt = vec![0.0; centers.len()];
|
||||
for ((_, c, n), lab) in used.iter().zip(&bins) {
|
||||
let j = nearest_lab(¢er_labs, *lab);
|
||||
if let (Some(s), Some(k)) = (sum.get_mut(j), cnt.get_mut(j)) {
|
||||
for ch in 0..3 {
|
||||
s[ch] += c[ch] * n;
|
||||
}
|
||||
*k += n;
|
||||
}
|
||||
}
|
||||
for (j, center) in centers.iter_mut().enumerate().take(fixed) {
|
||||
if let (Some(s), Some(&k)) = (sum.get(j), cnt.get(j))
|
||||
&& k > 0.0
|
||||
{
|
||||
*center = [s[0] / k, s[1] / k, s[2] / k];
|
||||
}
|
||||
}
|
||||
}
|
||||
centers
|
||||
}
|
||||
|
||||
/// Merge whole 4-connected regions, smallest first, into their most common neighbouring label.
|
||||
/// The merge order does not depend on `min_area`: a higher threshold continues the same sequence
|
||||
/// instead of splitting a region already merged at a lower threshold. Transparency is a barrier.
|
||||
pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
|
||||
denoise_keeping(labels, w, h, min_area, &[]);
|
||||
}
|
||||
|
||||
/// [`denoise`], except that a region of a label `keep` marks (a rare colour kept on purpose, see
|
||||
/// [`rare_colours`]) that is at least [`RARE_ISLAND`] pixels stays whatever `min_area` is: Noise
|
||||
/// clears specks, not a colour's only regions. Smaller regions of it go as any other.
|
||||
pub(crate) fn denoise_keeping(labels: &mut [u16], w: usize, h: usize, min_area: usize, keep: &[bool]) {
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BinaryHeap};
|
||||
|
||||
@@ -737,6 +983,9 @@ pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
|
||||
if regions[id].pixels.len() != area {
|
||||
continue; // absorbed region or an old heap entry for a region that grew
|
||||
}
|
||||
if area >= RARE_ISLAND && keep.get(regions[id].label as usize).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let mut borders: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
for &i in ®ions[id].pixels {
|
||||
for j in neighbours(i).into_iter().flatten() {
|
||||
|
||||
+211
-1
@@ -602,7 +602,7 @@ fn create_strokes_survives_noise_and_tiny_images() {
|
||||
seed
|
||||
};
|
||||
let noise: Vec<u8> = (0..120 * 120).flat_map(|_| if next() % 3 == 0 { BLACK } else { WHITE }).collect();
|
||||
let noise = Raster::new(120, 120, noise);
|
||||
let noise = Raster::new(120, 120, noise).unwrap();
|
||||
for img in [noise, Raster::from_fn(1, 1, |_, _| BLACK), Raster::from_fn(3, 2, |x, _| if x == 1 { BLACK } else { WHITE })] {
|
||||
for (fills, width) in [(true, 1.0), (false, 4.0), (true, 100.0)] {
|
||||
for noise in [0, 1, 4] {
|
||||
@@ -1652,3 +1652,213 @@ fn a_higher_paths_never_gives_fewer_segments() {
|
||||
}
|
||||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_of_the_wrong_length_is_an_error_not_a_crash() {
|
||||
for (w, h, len) in [(4, 4, 63), (4, 4, 65), (4, 4, 0), (0, 4, 4), (1, 1, 3), (u32::MAX, u32::MAX, 16)] {
|
||||
match Raster::new(w, h, vec![7; len]) {
|
||||
Err(TraceError::Decode(why)) => assert!(why.starts_with(&format!("{len} bytes")) && why.contains(&format!("{w} × {h}")), "{why}"),
|
||||
other => panic!("{w} × {h}, {len} bytes: {other:?}"),
|
||||
}
|
||||
}
|
||||
let e = Raster::new(3, 2, vec![0; 20]).unwrap_err().to_string();
|
||||
assert!(e.contains("20 bytes") && e.contains("3 × 2"), "{e}");
|
||||
assert_eq!(Raster::new(3, 2, vec![9; 24]).unwrap().pixel(2, 1), [9; 4]);
|
||||
assert!(Raster::new(0, 0, vec![]).is_ok());
|
||||
// A raster whose public fields disagree is still no crash: no pixel past its bytes, no trace,
|
||||
// no mosaic tile out of range.
|
||||
let broken = Raster { width: 8, height: 8, rgba: vec![255; 40] };
|
||||
assert_eq!(broken.pixel(7, 7), [0; 4]);
|
||||
assert_eq!(broken.pixel(1, 1), [255; 4]);
|
||||
assert_eq!(broken.pixel(9, 0), [0; 4]);
|
||||
assert!(trace(&broken, &TraceParams::default()).paths.is_empty());
|
||||
assert_eq!(mosaic(&broken, 2, 2).len(), 4);
|
||||
}
|
||||
|
||||
// ---- Automatic keeps rare but distinct colours (code-drawn flat art) ----
|
||||
|
||||
/// Where a layer of [`flat_art`] is.
|
||||
type Inside<'a> = &'a dyn Fn(f64, f64) -> bool;
|
||||
/// A layer of [`flat_art`], owning where it is.
|
||||
type Layer = ([u8; 3], Box<dyn Fn(f64, f64) -> bool>);
|
||||
|
||||
/// Flat art drawn in code at `scale` (1 or 2) with 4 × 4 samples a pixel (anti-aliased) or one
|
||||
/// (hard): `layers` painted in order, each a colour and where it is (in 1× units).
|
||||
fn flat_art(w: u32, h: u32, scale: u32, aa: bool, layers: &[([u8; 3], Inside)]) -> Raster {
|
||||
let n = if aa { 4 } else { 1 };
|
||||
Raster::from_fn(w * scale, h * scale, |x, y| {
|
||||
let mut acc = [0.0f64; 3];
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let (fx, fy) = (
|
||||
(f64::from(x) + (f64::from(i) + 0.5) / f64::from(n)) / f64::from(scale),
|
||||
(f64::from(y) + (f64::from(j) + 0.5) / f64::from(n)) / f64::from(scale),
|
||||
);
|
||||
let c = layers.iter().rev().find(|(_, inside)| inside(fx, fy)).map_or([255, 255, 255], |l| l.0);
|
||||
for k in 0..3 {
|
||||
acc[k] += f64::from(c[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = acc.map(|v| (v / f64::from(n * n)).round() as u8);
|
||||
[c[0], c[1], c[2], 255]
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the trace has a colour within `de` (Oklab × 100) of `c`.
|
||||
fn has_colour(res: &TraceResult, c: [u8; 3], de: f64) -> bool {
|
||||
let target = quantize::tests_oklab(c);
|
||||
res.paths.iter().any(|p| {
|
||||
let o = quantize::tests_oklab(p.color);
|
||||
(0..3).map(|k| (o[k] - target[k]).powi(2)).sum::<f64>().sqrt() <= de
|
||||
})
|
||||
}
|
||||
|
||||
fn distinct_colours(res: &TraceResult) -> usize {
|
||||
let mut v: Vec<[u8; 3]> = res.paths.iter().map(|p| p.color).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v.len()
|
||||
}
|
||||
|
||||
const LARGE: [[u8; 3]; 8] =
|
||||
[[245, 240, 228], [30, 60, 140], [200, 40, 50], [40, 140, 70], [240, 190, 40], [90, 50, 30], [120, 120, 130], [20, 20, 25]];
|
||||
|
||||
#[test]
|
||||
fn automatic_keeps_a_rare_distinct_stem_or_patch() {
|
||||
// 2–8 large flat colours (bands) and a contrasting stem 1–4 px wide, or a square patch, from
|
||||
// about 0.05% to 2% of the picture, hard and anti-aliased at 1× and 2×.
|
||||
let rare = [[230, 30, 200], [0, 200, 220], [255, 255, 255]];
|
||||
let params = preset("Auto Color").unwrap();
|
||||
let mut failed = vec![];
|
||||
for k in [2usize, 3, 5, 8] {
|
||||
for (ri, &stem) in rare.iter().enumerate() {
|
||||
for (width, length) in [(1.0, 20.0), (2.0, 30.0), (4.0, 50.0), (6.0, 6.0), (14.0, 14.0)] {
|
||||
for (scale, aa) in [(1, false), (1, true), (2, true)] {
|
||||
let bands = move |x: f64, _: f64| ((x / 200.0 * k as f64) as usize).min(k - 1);
|
||||
let band_colour = |b: usize| LARGE[(b + ri) % 8];
|
||||
// The stem goes where it contrasts with the band under it.
|
||||
let (x0, y0) = (12.0, 40.0);
|
||||
if band_colour(bands(x0, y0)) == stem {
|
||||
continue;
|
||||
}
|
||||
let layers: Vec<Layer> = (0..k)
|
||||
.map(|b| (band_colour(b), Box::new(move |x: f64, y: f64| bands(x, y) == b) as Box<dyn Fn(f64, f64) -> bool>))
|
||||
.chain(std::iter::once((
|
||||
stem,
|
||||
Box::new(move |x: f64, y: f64| (x0..x0 + width).contains(&x) && (y0..y0 + length).contains(&y))
|
||||
as Box<dyn Fn(f64, f64) -> bool>,
|
||||
)))
|
||||
.collect();
|
||||
let refs: Vec<([u8; 3], Inside)> = layers.iter().map(|(c, f)| (*c, f.as_ref())).collect();
|
||||
let img = flat_art(200, 200, scale, aa, &refs);
|
||||
let res = trace(&img, ¶ms);
|
||||
let share = width * length / 40000.0 * 100.0;
|
||||
if !has_colour(&res, stem, 3.0) {
|
||||
failed.push(format!(
|
||||
"{k} colours, stem {stem:?} {width}×{length} ({share:.2}%), ×{scale} aa {aa}: lost, {} colours",
|
||||
distinct_colours(&res)
|
||||
));
|
||||
} else if distinct_colours(&res) > k + 1 {
|
||||
failed.push(format!("{k} colours, stem {stem:?} {width}×{length}, ×{scale} aa {aa}: {} colours", distinct_colours(&res)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_keeps_near_white_highlights_on_a_pale_background() {
|
||||
// White highlights (ten islands of about 10–20 px, 0.2–0.4% in all) on #FFF4E0, with brown and
|
||||
// dark shapes: the white is 4 (Oklab × 100) from the background, the islands under Noise.
|
||||
let bg = [255, 244, 224];
|
||||
let params = preset("Auto Color").unwrap();
|
||||
for (scale, aa) in [(1, false), (1, true), (2, true)] {
|
||||
let face = |x: f64, y: f64| (x - 100.0).hypot(y - 100.0) < 60.0;
|
||||
let eyes = |x: f64, y: f64| (x - 80.0).hypot(y - 90.0) < 9.0 || (x - 120.0).hypot(y - 90.0) < 9.0;
|
||||
let shine = |x: f64, y: f64| (0..10).any(|i| (x - (40.0 + 13.0 * f64::from(i))).hypot(y - 30.0) < 2.0);
|
||||
let layers: [([u8; 3], Inside); 4] = [(bg, &|_, _| true), ([139, 90, 43], &face), ([46, 31, 20], &eyes), ([255, 255, 255], &shine)];
|
||||
let img = flat_art(200, 200, scale, aa, &layers);
|
||||
let res = trace(&img, ¶ms);
|
||||
assert!(has_colour(&res, [255, 255, 255], 1.5), "×{scale} aa {aa}: white lost: {:?}", res.palette);
|
||||
assert!(has_colour(&res, bg, 1.5), "×{scale} aa {aa}: background lost: {:?}", res.palette);
|
||||
assert_eq!(distinct_colours(&res), 4, "×{scale} aa {aa}: {:?}", res.palette);
|
||||
// The highlights survive Noise (16 in Auto Color): each is a region of its own.
|
||||
let white = res.paths.iter().filter(|p| p.color == [255, 255, 255]).count();
|
||||
assert!(white >= 10, "×{scale} aa {aa}: {white} white paths");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_keeps_a_thin_low_contrast_stem() {
|
||||
// A coherent 3 px stem #909090 on #808080 beside a dark area: close in RGB (28 levels), so
|
||||
// merge_mixed would take it for noise, but it is one region, not intermixed.
|
||||
let params = preset("Auto Color").unwrap();
|
||||
for (scale, aa) in [(1, false), (1, true), (2, true)] {
|
||||
let stem = |x: f64, y: f64| (60.0..63.0).contains(&x) && (20.0..180.0).contains(&y);
|
||||
let dark = |x: f64, _: f64| x > 130.0;
|
||||
let layers: [([u8; 3], Inside); 3] = [([128, 128, 128], &|_, _| true), ([32, 32, 32], &dark), ([144, 144, 144], &stem)];
|
||||
let res = trace(&flat_art(200, 200, scale, aa, &layers), ¶ms);
|
||||
assert!(has_colour(&res, [144, 144, 144], 1.5), "×{scale} aa {aa}: stem lost: {:?}", res.palette);
|
||||
assert_eq!(distinct_colours(&res), 3, "×{scale} aa {aa}: {:?}", res.palette);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scattered_noise_adds_no_colour() {
|
||||
// The negative control: the same areas as a stem (0.05% to 2%) as scattered single pixels of a
|
||||
// distinct colour, seeded, over 2–5 flat colours: no palette entry of their own.
|
||||
let params = preset("Auto Color").unwrap();
|
||||
let hash =
|
||||
|x: u32, y: u32, s: u32| (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663) ^ s.wrapping_mul(83_492_791)).wrapping_mul(2_654_435_761);
|
||||
for k in [2usize, 3, 5] {
|
||||
for (seed, per_mille) in [(1u32, 1u32), (2, 5), (3, 10), (4, 20)] {
|
||||
let img = Raster::from_fn(200, 200, |x, y| {
|
||||
let b = ((f64::from(x) / 200.0 * k as f64) as usize).min(k - 1);
|
||||
let c = if hash(x, y, seed) % 1000 < per_mille { [230, 30, 200] } else { LARGE[b] };
|
||||
[c[0], c[1], c[2], 255]
|
||||
});
|
||||
let res = trace(&img, ¶ms);
|
||||
assert!(!has_colour(&res, [230, 30, 200], 8.0), "{k} colours, {per_mille}‰ noise: {:?}", res.palette);
|
||||
assert!(distinct_colours(&res) <= k, "{k} colours, {per_mille}‰ noise: {:?}", res.palette);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_keeps_a_kept_rare_colours_regions_but_not_its_specks() {
|
||||
// Labels: 0 background, 1 a rare colour kept on purpose (four islands of 4, 6, 9 and 2
|
||||
// pixels), 2 an ordinary colour with an island of 6.
|
||||
let (w, h) = (40usize, 10usize);
|
||||
let mut labels = vec![0u16; w * h];
|
||||
let mut put = |x0: usize, y0: usize, cw: usize, ch: usize, l: u16| {
|
||||
for y in y0..y0 + ch {
|
||||
for x in x0..x0 + cw {
|
||||
labels[y * w + x] = l;
|
||||
}
|
||||
}
|
||||
};
|
||||
put(2, 2, 2, 2, 1);
|
||||
put(8, 2, 3, 2, 1);
|
||||
put(14, 2, 3, 3, 1);
|
||||
put(20, 2, 2, 1, 1);
|
||||
put(28, 2, 3, 2, 2);
|
||||
for noise in [0usize, 4, 10, 16, 32] {
|
||||
let mut l = labels.clone();
|
||||
quantize::denoise_keeping(&mut l, w, h, noise, &[false, true, false]);
|
||||
let count = |label: u16| l.iter().filter(|&&v| v == label).count();
|
||||
// The rare colour's regions of at least four pixels stay whatever Noise is; its speck of
|
||||
// two goes from Noise 4 on, as the ordinary colour's island of six goes from Noise 10.
|
||||
assert_eq!(count(1), if noise >= 4 { 4 + 6 + 9 } else { 4 + 6 + 9 + 2 }, "Noise {noise}");
|
||||
assert_eq!(count(2), if noise >= 10 { 0 } else { 6 }, "Noise {noise}");
|
||||
}
|
||||
// Without a kept label, denoise is as before.
|
||||
let mut a = labels.clone();
|
||||
let mut b = labels.clone();
|
||||
denoise(&mut a, w, h, 16);
|
||||
quantize::denoise_keeping(&mut b, w, h, 16, &[]);
|
||||
assert_eq!(a, b);
|
||||
assert!(a.iter().all(|&v| v == 0));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Point arithmetic for the code-drawn centre-line scenes.
|
||||
|
||||
use std::ops::{Add, Mul, Neg, Sub};
|
||||
|
||||
/// A point or vector in pixel coordinates (pixel `(x, y)` covers `[x, x+1] × [y, y+1]`, y down).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct P {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
impl P {
|
||||
pub const fn new(x: f64, y: f64) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
pub fn dot(self, o: P) -> f64 {
|
||||
self.x * o.x + self.y * o.y
|
||||
}
|
||||
pub fn len(self) -> f64 {
|
||||
self.x.hypot(self.y)
|
||||
}
|
||||
pub fn dist(self, o: P) -> f64 {
|
||||
(self - o).len()
|
||||
}
|
||||
/// Unit vector, or `None` for a (near) zero vector.
|
||||
pub fn unit(self) -> Option<P> {
|
||||
let l = self.len();
|
||||
(l > 1e-12 && l.is_finite()).then(|| P::new(self.x / l, self.y / l))
|
||||
}
|
||||
pub fn lerp(self, o: P, t: f64) -> P {
|
||||
self + (o - self) * t
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for P {
|
||||
type Output = P;
|
||||
fn add(self, o: P) -> P {
|
||||
P::new(self.x + o.x, self.y + o.y)
|
||||
}
|
||||
}
|
||||
impl Sub for P {
|
||||
type Output = P;
|
||||
fn sub(self, o: P) -> P {
|
||||
P::new(self.x - o.x, self.y - o.y)
|
||||
}
|
||||
}
|
||||
impl Mul<f64> for P {
|
||||
type Output = P;
|
||||
fn mul(self, k: f64) -> P {
|
||||
P::new(self.x * k, self.y * k)
|
||||
}
|
||||
}
|
||||
impl Neg for P {
|
||||
type Output = P;
|
||||
fn neg(self) -> P {
|
||||
P::new(-self.x, -self.y)
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from `q` to segment `ab`, and the parameter of the nearest point.
|
||||
pub fn seg_dist(q: P, a: P, b: P) -> (f64, f64) {
|
||||
let ab = b - a;
|
||||
let l2 = ab.dot(ab);
|
||||
if l2 < 1e-18 {
|
||||
return (q.dist(a), 0.0);
|
||||
}
|
||||
let t = ((q - a).dot(ab) / l2).clamp(0.0, 1.0);
|
||||
(q.dist(a + ab * t), t)
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
//! Centre-line measurements on code-drawn scenes. No external images or tracer required.
|
||||
use super::centre_geom::{P, seg_dist};
|
||||
use super::centre_scenes::{self as scenes, Scene};
|
||||
use kurbo::{BezPath, ParamCurve, PathSeg};
|
||||
use std::fmt::Write as _;
|
||||
use std::time::Instant;
|
||||
use vectorcraft_trace::{Raster, TraceParams, TraceResult, trace_within};
|
||||
|
||||
/// Nearest-point queries over a point set (uniform grid buckets).
|
||||
struct Near {
|
||||
cell: f64,
|
||||
gw: usize,
|
||||
gh: usize,
|
||||
buckets: Vec<Vec<usize>>,
|
||||
pts: Vec<P>,
|
||||
}
|
||||
|
||||
impl Near {
|
||||
fn new(pts: Vec<P>, w: usize, h: usize, cell: f64) -> Self {
|
||||
let gw = (w as f64 / cell).ceil() as usize + 2;
|
||||
let gh = (h as f64 / cell).ceil() as usize + 2;
|
||||
let mut buckets = vec![vec![]; gw * gh];
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let (cx, cy) = (((p.x / cell).floor().max(0.0) as usize).min(gw - 1), ((p.y / cell).floor().max(0.0) as usize).min(gh - 1));
|
||||
buckets[cy * gw + cx].push(i);
|
||||
}
|
||||
Self { cell, gw, gh, buckets, pts }
|
||||
}
|
||||
/// Nearest point within `max` (index, distance).
|
||||
fn nearest(&self, q: P, max: f64) -> Option<(usize, f64)> {
|
||||
let r = (max / self.cell).ceil() as i64;
|
||||
let (cx, cy) = ((q.x / self.cell).floor() as i64, (q.y / self.cell).floor() as i64);
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for y in cy - r..=cy + r {
|
||||
for x in cx - r..=cx + r {
|
||||
if x < 0 || y < 0 || x >= self.gw as i64 || y >= self.gh as i64 {
|
||||
continue;
|
||||
}
|
||||
for &i in &self.buckets[y as usize * self.gw + x as usize] {
|
||||
let d = self.pts[i].dist(q);
|
||||
if d <= max && best.is_none_or(|b| d < b.1) {
|
||||
best = Some((i, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
fn mean(v: &[f64]) -> f64 {
|
||||
if v.is_empty() { f64::NAN } else { v.iter().sum::<f64>() / v.len() as f64 }
|
||||
}
|
||||
fn p95(v: &[f64]) -> f64 {
|
||||
if v.is_empty() {
|
||||
return f64::NAN;
|
||||
}
|
||||
let mut s = v.to_vec();
|
||||
s.sort_by(f64::total_cmp);
|
||||
s[((s.len() - 1) as f64 * 0.95).round() as usize]
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
struct Metrics {
|
||||
name: String,
|
||||
input: &'static str,
|
||||
strokes: usize,
|
||||
fills: usize,
|
||||
anchors: usize,
|
||||
ms: f64,
|
||||
c_mean: f64,
|
||||
c_p95: f64,
|
||||
g_mean: f64,
|
||||
g_p95: f64,
|
||||
complete: f64,
|
||||
w_uni: f64,
|
||||
w_uni_p95: f64,
|
||||
junc_total: usize,
|
||||
junc_ok: usize,
|
||||
junc_wrong: usize,
|
||||
junc_extra: usize,
|
||||
junc_pos: f64,
|
||||
ends_gt: usize,
|
||||
ends_found: usize,
|
||||
end_err: f64,
|
||||
end_signed: f64,
|
||||
spurs: usize,
|
||||
iou_uni: f64,
|
||||
area_uni: f64,
|
||||
fill_as_fill: f64,
|
||||
}
|
||||
|
||||
fn iou(a: &[bool], m: &[bool]) -> (f64, f64) {
|
||||
let (mut i, mut u, mut ra, mut rm) = (0usize, 0usize, 0usize, 0usize);
|
||||
for (x, y) in a.iter().zip(m) {
|
||||
i += usize::from(*x && *y);
|
||||
u += usize::from(*x || *y);
|
||||
ra += usize::from(*x);
|
||||
rm += usize::from(*y);
|
||||
}
|
||||
(i as f64 / u.max(1) as f64, (ra as f64 - rm as f64) / rm.max(1) as f64)
|
||||
}
|
||||
|
||||
fn evaluate(sc: &Scene, input: &'static str, cov: &[f32]) -> Metrics {
|
||||
let rgba = cov
|
||||
.iter()
|
||||
.flat_map(|&c| {
|
||||
let v = ((1.0 - c) * 255.0).round() as u8;
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
let img = Raster::new(sc.w as u32, sc.h as u32, rgba).unwrap();
|
||||
let params = TraceParams { strokes: true, fills: true, stroke_width: sc.wmax, ignore_white: true, noise: 4, ..TraceParams::default() };
|
||||
let t0 = Instant::now();
|
||||
let res = trace_within(&img, ¶ms, super::MAX_ANCHORS).unwrap();
|
||||
let ms = t0.elapsed().as_secs_f64() * 1e3;
|
||||
measure(sc, input, cov, &res, ms)
|
||||
}
|
||||
|
||||
fn measure(sc: &Scene, input: &'static str, cov: &[f32], res: &TraceResult, ms: f64) -> Metrics {
|
||||
let t = geometry(res);
|
||||
let in_fill = |q: P, grow: f64| sc.fills.iter().any(|f| f.dist(q) <= grow);
|
||||
// Ground-truth centre samples (outside fills) and output samples.
|
||||
let mut gt_pts = vec![];
|
||||
let mut gt_w = vec![];
|
||||
for s in &sc.strokes {
|
||||
for (p, w) in s.pts.iter().zip(&s.w) {
|
||||
if !in_fill(*p, 0.0) {
|
||||
gt_pts.push(*p);
|
||||
gt_w.push(*w);
|
||||
}
|
||||
}
|
||||
}
|
||||
let gt = Near::new(gt_pts.clone(), sc.w, sc.h, 4.0);
|
||||
let mut out_pts = vec![];
|
||||
let (mut cd, mut wu) = (vec![], vec![]);
|
||||
for s in &t.strokes {
|
||||
for (p, _) in &s.samples {
|
||||
if in_fill(*p, 0.0) {
|
||||
continue;
|
||||
}
|
||||
out_pts.push(*p);
|
||||
if let Some((i, d)) = gt.nearest(*p, 30.0) {
|
||||
cd.push(d);
|
||||
// Width: only where the sample is on a stroke (not a stray piece).
|
||||
if d < 2.0 {
|
||||
wu.push((s.width - gt_w[i]).abs());
|
||||
}
|
||||
} else {
|
||||
cd.push(30.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = Near::new(out_pts, sc.w, sc.h, 4.0);
|
||||
let gd: Vec<f64> = gt_pts.iter().map(|&p| out.nearest(p, 30.0).map_or(30.0, |x| x.1)).collect();
|
||||
let complete = gd.iter().filter(|&&d| d <= 1.5).count() as f64 / gd.len().max(1) as f64;
|
||||
// Junctions (away from fills).
|
||||
let outj: Vec<_> = t.junctions.iter().filter(|j| !in_fill(j.pos, 2.0)).collect();
|
||||
let mut used = vec![false; outj.len()];
|
||||
let (mut ok, mut wrong, mut pos) = (0, 0, vec![]);
|
||||
for &(p, deg) in &sc.junctions {
|
||||
let wl = gt.nearest(p, 5.0).map_or(4.0, |(i, _)| gt_w[i]);
|
||||
let tol = wl + 3.0;
|
||||
let best =
|
||||
outj.iter().enumerate().filter(|(k, j)| !used[*k] && j.pos.dist(p) <= tol).min_by(|a, b| a.1.pos.dist(p).total_cmp(&b.1.pos.dist(p)));
|
||||
if let Some((k, j)) = best {
|
||||
used[k] = true;
|
||||
pos.push(j.pos.dist(p));
|
||||
if j.degree == deg {
|
||||
ok += 1;
|
||||
} else {
|
||||
wrong += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let extra = used.iter().filter(|u| !**u).count();
|
||||
// Free ends: ground truth = open stroke ends not at a junction, corner or fill.
|
||||
let mut gt_ends = vec![];
|
||||
for s in sc.strokes.iter().filter(|s| !s.closed) {
|
||||
for (p, w) in [(s.pts[0], s.w[0]), (*s.pts.last().unwrap_or(&s.pts[0]), *s.w.last().unwrap_or(&s.w[0]))] {
|
||||
let near_j = sc.junctions.iter().any(|(q, _)| q.dist(p) < 1.0) || sc.corners.iter().any(|q| q.dist(p) < 1.0);
|
||||
let on_other = sc.strokes.iter().filter(|o| !std::ptr::eq(*o, s)).any(|o| o.pts.iter().any(|q| q.dist(p) < 0.5));
|
||||
if !near_j && !on_other && !in_fill(p, w) {
|
||||
// Its direction (outwards), for the signed error.
|
||||
let k =
|
||||
if p == s.pts[0] { (s.pts[0] - s.pts[s.pts.len().min(9) - 1]).unit() } else { (p - s.pts[s.pts.len().saturating_sub(9)]).unit() };
|
||||
gt_ends.push((p, w, k.unwrap_or(P::new(1.0, 0.0))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let out_ends: Vec<P> = t.ends.iter().copied().filter(|p| !in_fill(*p, 2.0)).collect();
|
||||
let mut used_e = vec![false; out_ends.len()];
|
||||
let (mut found, mut err, mut signed) = (0, vec![], vec![]);
|
||||
for &(p, w, dir) in >_ends {
|
||||
let tol = 0.5 * w + 4.0;
|
||||
let best = out_ends.iter().enumerate().filter(|(k, q)| !used_e[*k] && q.dist(p) <= tol).min_by(|a, b| a.1.dist(p).total_cmp(&b.1.dist(p)));
|
||||
if let Some((k, q)) = best {
|
||||
used_e[k] = true;
|
||||
found += 1;
|
||||
err.push(q.dist(p));
|
||||
signed.push((*q - p).dot(dir));
|
||||
}
|
||||
}
|
||||
let spurs = used_e.iter().filter(|u| !**u).count();
|
||||
let r_uni = render(res, sc.w, sc.h, false);
|
||||
// Coverage against the exact shape (pixel centres inside it); the thresholded mask
|
||||
// over-counts pixels exactly half covered.
|
||||
let exact = sc.exact();
|
||||
let (iou_uni, area_uni) = iou(&r_uni, &exact);
|
||||
// Fill pixels traced as fills.
|
||||
let rf = render(res, sc.w, sc.h, true);
|
||||
let (mut fp, mut ff) = (0usize, 0usize);
|
||||
for y in 0..sc.h {
|
||||
for x in 0..sc.w {
|
||||
let q = P::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
if cov[y * sc.w + x] >= 0.5 && sc.fills.iter().any(|f| f.inside(q)) {
|
||||
fp += 1;
|
||||
ff += usize::from(rf[y * sc.w + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Metrics {
|
||||
name: sc.name.clone(),
|
||||
input,
|
||||
strokes: res.paths.iter().filter(|p| p.stroke.is_some()).count(),
|
||||
fills: t.fills.len(),
|
||||
anchors: res.paths.iter().filter(|p| p.stroke.is_some()).map(|p| p.path.anchor_count()).sum(),
|
||||
ms,
|
||||
c_mean: mean(&cd),
|
||||
c_p95: p95(&cd),
|
||||
g_mean: mean(&gd),
|
||||
g_p95: p95(&gd),
|
||||
complete,
|
||||
w_uni: mean(&wu),
|
||||
w_uni_p95: p95(&wu),
|
||||
junc_total: sc.junctions.len(),
|
||||
junc_ok: ok,
|
||||
junc_wrong: wrong,
|
||||
junc_extra: extra,
|
||||
junc_pos: mean(&pos),
|
||||
ends_gt: gt_ends.len(),
|
||||
ends_found: found,
|
||||
end_err: mean(&err),
|
||||
end_signed: mean(&signed),
|
||||
spurs,
|
||||
iou_uni,
|
||||
area_uni,
|
||||
fill_as_fill: if fp > 0 { ff as f64 / fp as f64 } else { f64::NAN },
|
||||
}
|
||||
}
|
||||
|
||||
struct StrokeSamples {
|
||||
samples: Vec<(P, f64)>,
|
||||
closed: bool,
|
||||
width: f64,
|
||||
}
|
||||
struct Junction {
|
||||
pos: P,
|
||||
degree: usize,
|
||||
}
|
||||
struct Geometry {
|
||||
strokes: Vec<StrokeSamples>,
|
||||
fills: Vec<()>,
|
||||
junctions: Vec<Junction>,
|
||||
ends: Vec<P>,
|
||||
}
|
||||
|
||||
fn point(p: kurbo::Point) -> P {
|
||||
P::new(p.x, p.y)
|
||||
}
|
||||
|
||||
/// Evaluate the emitted curves at <=0.5 px control-polygon steps (as for the scene
|
||||
/// yardstick). Keep subpaths separate: today's compound strokes share one width.
|
||||
fn samples(path: &BezPath) -> Vec<P> {
|
||||
let mut pts = vec![];
|
||||
for seg in path.segments() {
|
||||
let poly = match seg {
|
||||
PathSeg::Line(l) => l.p0.distance(l.p1),
|
||||
PathSeg::Quad(q) => q.p0.distance(q.p1) + q.p1.distance(q.p2),
|
||||
PathSeg::Cubic(c) => c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3),
|
||||
};
|
||||
let n = (poly / 0.5).ceil().max(1.0) as usize;
|
||||
if pts.is_empty() {
|
||||
pts.push(point(seg.eval(0.0)));
|
||||
}
|
||||
for i in 1..=n {
|
||||
pts.push(point(seg.eval(i as f64 / n as f64)));
|
||||
}
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Recover topology from emitted geometry, without consulting scene truth. Candidates
|
||||
/// are open endpoints and intersections. Coincident points merge within 0.05 px;
|
||||
/// each touching endpoint contributes one arm, each interior contributes two.
|
||||
/// This also measures a bar chained through a T, or two chained strokes crossing.
|
||||
fn geometry(res: &TraceResult) -> Geometry {
|
||||
let mut strokes = vec![];
|
||||
for path in &res.paths {
|
||||
if let Some(width) = path.stroke {
|
||||
for sp in &path.path.subpaths {
|
||||
let mut bez = BezPath::new();
|
||||
sp.to_bezpath_into(&mut bez);
|
||||
strokes.push(StrokeSamples { samples: samples(&bez).into_iter().map(|p| (p, width)).collect(), closed: sp.closed, width });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut candidates = vec![];
|
||||
for s in &strokes {
|
||||
if !s.closed && !s.samples.is_empty() {
|
||||
candidates.extend([s.samples[0].0, s.samples.last().unwrap().0]);
|
||||
}
|
||||
}
|
||||
for (i, a) in strokes.iter().enumerate() {
|
||||
for b in &strokes[i + 1..] {
|
||||
for aa in a.samples.windows(2) {
|
||||
let (p, r) = (aa[0].0, aa[1].0 - aa[0].0);
|
||||
for bb in b.samples.windows(2) {
|
||||
let (q, s) = (bb[0].0, bb[1].0 - bb[0].0);
|
||||
if aa[0].0.x.max(aa[1].0.x) < bb[0].0.x.min(bb[1].0.x)
|
||||
|| bb[0].0.x.max(bb[1].0.x) < aa[0].0.x.min(aa[1].0.x)
|
||||
|| aa[0].0.y.max(aa[1].0.y) < bb[0].0.y.min(bb[1].0.y)
|
||||
|| bb[0].0.y.max(bb[1].0.y) < aa[0].0.y.min(aa[1].0.y)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let cross = |v: P, w: P| v.x * w.y - v.y * w.x;
|
||||
let d = cross(r, s);
|
||||
if d.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let t = cross(q - p, s) / d;
|
||||
let u = cross(q - p, r) / d;
|
||||
if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) {
|
||||
candidates.push(p + r * t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut nodes: Vec<P> = vec![];
|
||||
for p in candidates {
|
||||
if !nodes.iter().any(|q| q.dist(p) <= 0.05) {
|
||||
nodes.push(p);
|
||||
}
|
||||
}
|
||||
let (mut junctions, mut ends) = (vec![], vec![]);
|
||||
for pos in nodes {
|
||||
let degree = strokes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
if s.samples.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
if !s.closed {
|
||||
// A run returning to its junction contributes both endpoint arms.
|
||||
let arms = usize::from(pos.dist(s.samples[0].0) <= 0.05) + usize::from(pos.dist(s.samples.last().unwrap().0) <= 0.05);
|
||||
if arms > 0 {
|
||||
return arms;
|
||||
}
|
||||
}
|
||||
if s.samples.windows(2).any(|p| seg_dist(pos, p[0].0, p[1].0).0 <= 0.05) { 2 } else { 0 }
|
||||
})
|
||||
.sum();
|
||||
if degree >= 3 {
|
||||
junctions.push(Junction { pos, degree });
|
||||
}
|
||||
if degree == 1 {
|
||||
ends.push(pos);
|
||||
}
|
||||
}
|
||||
Geometry { strokes, fills: vec![(); res.paths.iter().filter(|p| p.stroke.is_none()).count()], junctions, ends }
|
||||
}
|
||||
|
||||
/// Pixel-centre nonzero-winding fill of the actual painted paths. Strokes retain
|
||||
/// their emitted square/round caps. Overlapping paths are unioned, never cancelled.
|
||||
fn render(res: &TraceResult, w: usize, h: usize, fills_only: bool) -> Vec<bool> {
|
||||
let mut cov = vec![false; w * h];
|
||||
for path in &res.paths {
|
||||
if fills_only && path.stroke.is_some() {
|
||||
continue;
|
||||
}
|
||||
let bez = super::measure::painted(path);
|
||||
let polys = super::raster::polylines(&bez, 0.02);
|
||||
let mut xs = vec![];
|
||||
for y in 0..h {
|
||||
let yc = y as f64 + 0.5;
|
||||
xs.clear();
|
||||
for poly in &polys {
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
|
||||
if (a.y <= yc) != (b.y <= yc) {
|
||||
xs.push((a.x + (b.x - a.x) * (yc - a.y) / (b.y - a.y), if b.y > a.y { 1 } else { -1 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut wind = 0;
|
||||
for pair in xs.windows(2) {
|
||||
wind += pair[0].1;
|
||||
if wind != 0 {
|
||||
let xa = (pair[0].0 - 0.5).ceil().max(0.0) as usize;
|
||||
let xb = ((pair[1].0 - 0.5).floor() + 1.0).max(0.0) as usize;
|
||||
for x in xa.min(w)..xb.min(w) {
|
||||
cov[y * w + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cov
|
||||
}
|
||||
|
||||
fn f(v: f64, d: usize) -> String {
|
||||
if v.is_nan() { "–".into() } else { format!("{v:.d$}") }
|
||||
}
|
||||
|
||||
pub fn report() -> String {
|
||||
let mut rows = vec![];
|
||||
for sc in scenes::all() {
|
||||
let cov = sc.rasterise();
|
||||
let hard: Vec<f32> = cov.iter().map(|&c| if c >= 0.5 { 1.0 } else { 0.0 }).collect();
|
||||
for (input, c) in [("aa", &cov), ("hard", &hard)] {
|
||||
rows.push(evaluate(&sc, input, c));
|
||||
}
|
||||
}
|
||||
let mut md = String::from(
|
||||
"## Centre lines\n\nCode-drawn scenes only: 23 scenes, each anti-aliased (8×8 coverage samples) and hard (that coverage thresholded at 0.5). Coordinates, widths and scene Stroke values are fixed; junction/letter/crossing coordinates include the 0.27/0.41 px offset. Create Fills and Strokes are on, Stroke is the scene's limit (13 px, mix 6 px, varying 12 px), Black and White threshold 128, Noise 4, Ignore White on, other settings at their defaults. Today's tracer has no minimum-length setting (the dashed scenes specify 0 for future use).\n\nDistances use dense exact centre samples (≤0.25 px), emitted curves sampled at ≤0.5 px control-polygon steps, and nearest-point searches capped at 30 px. Found is the fraction of true samples within 1.5 px; width errors use output samples within 2 px of a true stroke. Samples inside exact fills are excluded. Topology is recovered from emitted subpath endpoints and intersections, merging coincident positions within 0.05 px; an endpoint contributes one arm, an interior two. Junction matching radius is local true width + 3 px; ends use half-width + 4 px. Each match is used once. Free ends exclude true junctions, corners, other strokes and fills; unmatched emitted ends are spurs. Junctions/ends within 2 px of fills are excluded. End error is distance, with signed error along the outward tangent in parentheses. Coverage is a union of painted output at pixel centres (0.02 px flattening tolerance, actual emitted caps), compared with the exact swept shape at pixel centres, for both input variants. Area error is signed relative to exact area. Anchors counts stroke anchors, including all compound subpaths; paths counts stroked + filled paths. Time covers tracing only, one scene at a time. Profile-dependent columns are unavailable (–).\n\n### Centre line and width (px)\n\n| scene | input | paths | out→true mean / p95 | true→out mean / p95 | found % | width err uniform mean / p95 | variable mean / p95 | profile mean |\n|---|---|---|---|---|---|---|---|---|\n",
|
||||
);
|
||||
for m in &rows {
|
||||
writeln!(
|
||||
md,
|
||||
"| {} | {} | {}+{} | {} / {} | {} / {} | {} | {} / {} | – | – |",
|
||||
m.name,
|
||||
m.input,
|
||||
m.strokes,
|
||||
m.fills,
|
||||
f(m.c_mean, 2),
|
||||
f(m.c_p95, 2),
|
||||
f(m.g_mean, 2),
|
||||
f(m.g_p95, 2),
|
||||
f(100.0 * m.complete, 1),
|
||||
f(m.w_uni, 2),
|
||||
f(m.w_uni_p95, 2)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
md += "\n### Topology, ends, coverage\n\n| scene | input | junctions ok / wrong deg / missed / extra | junction pos err | ends found | end err (signed) | spurs | IoU uniform / profile | area err uniform / profile | fill→fill % | anchors | width pts | ms |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|\n";
|
||||
for m in &rows {
|
||||
writeln!(
|
||||
md,
|
||||
"| {} | {} | {} / {} / {} / {} | {} | {}/{} | {} ({}) | {} | {} / – | {}% / – | {} | {} | – | {} |",
|
||||
m.name,
|
||||
m.input,
|
||||
m.junc_ok,
|
||||
m.junc_wrong,
|
||||
m.junc_total - m.junc_ok - m.junc_wrong,
|
||||
m.junc_extra,
|
||||
f(m.junc_pos, 2),
|
||||
m.ends_found,
|
||||
m.ends_gt,
|
||||
f(m.end_err, 2),
|
||||
f(m.end_signed, 2),
|
||||
m.spurs,
|
||||
f(m.iou_uni, 3),
|
||||
f(100.0 * m.area_uni, 1),
|
||||
f(100.0 * m.fill_as_fill, 1),
|
||||
m.anchors,
|
||||
f(m.ms, 1)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let sum = |g: &dyn Fn(&Metrics) -> usize, inp: &str| rows.iter().filter(|m| m.input == inp).map(g).sum::<usize>();
|
||||
for inp in ["aa", "hard"] {
|
||||
writeln!(
|
||||
md,
|
||||
"\n**{inp} totals**: junctions {} correct, {} wrong degree, {} missed, {} extra (of {}); ends {} of {}; spurs {}.",
|
||||
sum(&|m| m.junc_ok, inp),
|
||||
sum(&|m| m.junc_wrong, inp),
|
||||
sum(&|m| m.junc_total - m.junc_ok - m.junc_wrong, inp),
|
||||
sum(&|m| m.junc_extra, inp),
|
||||
sum(&|m| m.junc_total, inp),
|
||||
sum(&|m| m.ends_found, inp),
|
||||
sum(&|m| m.ends_gt, inp),
|
||||
sum(&|m| m.spurs, inp)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_geometry_measures_junctions_ends_missing_ink_and_caps() {
|
||||
use vectorcraft_geom::PathData;
|
||||
use vectorcraft_trace::TracedPath;
|
||||
let make = |points: &[(f64, f64)], square_caps: bool| {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(points[0]);
|
||||
for &p in &points[1..] {
|
||||
path.line_to(p);
|
||||
}
|
||||
TracedPath { path: PathData::from_bezpath(&path), color: [0; 3], pixels: 0, stroke: Some(2.0), shape: None, gradient: None, square_caps }
|
||||
};
|
||||
// An interior meeting must count as two arms, irrespective of path chaining.
|
||||
let bar = make(&[(10.27, 20.41), (50.27, 20.41)], false);
|
||||
for (stem, degree, ends) in [(make(&[(30.27, 20.41), (30.27, 40.41)], false), 3, 3), (make(&[(30.27, 10.41), (30.27, 40.41)], false), 4, 4)] {
|
||||
let res = TraceResult { paths: vec![bar.clone(), stem], ..Default::default() };
|
||||
let g = geometry(&res);
|
||||
assert_eq!(g.junctions.len(), 1);
|
||||
assert_eq!(g.junctions[0].degree, degree);
|
||||
assert!(g.junctions[0].pos.dist(P::new(30.27, 20.41)) < 1e-9);
|
||||
assert_eq!(g.ends.len(), ends);
|
||||
}
|
||||
let returning = make(&[(30.27, 20.41), (45.27, 35.41), (15.27, 35.41), (30.27, 20.41)], false);
|
||||
let stem = make(&[(30.27, 20.41), (30.27, 10.41)], false);
|
||||
let g = geometry(&TraceResult { paths: vec![returning, stem], ..Default::default() });
|
||||
assert_eq!(g.junctions.len(), 1);
|
||||
assert_eq!(g.junctions[0].degree, 3);
|
||||
assert_eq!(g.ends.len(), 1);
|
||||
// Closed subpaths have no free ends, even if their first sample is repeated.
|
||||
let mut ring = BezPath::new();
|
||||
ring.move_to((10.0, 10.0));
|
||||
ring.line_to((20.0, 10.0));
|
||||
ring.line_to((20.0, 20.0));
|
||||
ring.close_path();
|
||||
let res = TraceResult { paths: vec![TracedPath { path: PathData::from_bezpath(&ring), ..bar.clone() }], ..Default::default() };
|
||||
let g = geometry(&res);
|
||||
assert!(g.ends.is_empty() && g.junctions.is_empty());
|
||||
|
||||
let mut sc = scenes::lines(2.0);
|
||||
sc.strokes.truncate(1);
|
||||
let st = &sc.strokes[0];
|
||||
let (a, b) = (st.pts[0], *st.pts.last().unwrap());
|
||||
let res = TraceResult { paths: vec![make(&[(a.x, a.y), (b.x, b.y)], false)], ..Default::default() };
|
||||
let cov = sc.rasterise();
|
||||
let m = measure(&sc, "aa", &cov, &res, 0.0);
|
||||
assert!(m.c_mean < 0.01 && m.g_p95 <= 0.251 && m.w_uni < 1e-9, "{m:?}");
|
||||
assert_eq!((m.ends_found, m.ends_gt, m.spurs), (2, 2, 0));
|
||||
assert!(m.end_err < 1e-9 && m.iou_uni > 0.999 && m.area_uni.abs() < 1e-9, "{m:?}");
|
||||
let missing = measure(&sc, "aa", &cov, &TraceResult::default(), 0.0);
|
||||
assert_eq!((missing.g_mean, missing.complete, missing.ends_found, missing.iou_uni, missing.area_uni), (30.0, 0.0, 0, 0.0, -1.0));
|
||||
let diagonal = make(&[(10.27, 10.41), (30.27, 30.41)], false);
|
||||
let round = render(&TraceResult { paths: vec![diagonal.clone()], ..Default::default() }, 50, 50, false);
|
||||
let square = render(&TraceResult { paths: vec![TracedPath { square_caps: true, ..diagonal }], ..Default::default() }, 50, 50, false);
|
||||
assert!(square.iter().filter(|p| **p).count() > round.iter().filter(|p| **p).count());
|
||||
// Preserve the exact catalogue and special scene limits.
|
||||
assert_eq!(scenes::all().len(), 23);
|
||||
assert_eq!((scenes::mix().wmax, scenes::varying().wmax, scenes::dashed(2.0).min_len), (6.0, 12.0, 0.0));
|
||||
assert_eq!(scenes::junctions(4.0).junctions[0].0, P::new(60.27, 40.41));
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Code-drawn test scenes with exact answers: centre lines with widths (round caps), filled
|
||||
//! areas, junctions with their degrees, and corners (stroke ends that meet but are no junction).
|
||||
|
||||
use super::centre_geom::{P, seg_dist};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GtStroke {
|
||||
/// Dense centre line (≤ 0.25 px apart).
|
||||
pub pts: Vec<P>,
|
||||
pub w: Vec<f64>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GtFill {
|
||||
Disc(P, f64),
|
||||
Poly(Vec<P>),
|
||||
}
|
||||
|
||||
impl GtFill {
|
||||
pub fn inside(&self, q: P) -> bool {
|
||||
match self {
|
||||
GtFill::Disc(c, r) => q.dist(*c) <= *r,
|
||||
GtFill::Poly(p) => {
|
||||
let n = p.len();
|
||||
let mut inside = false;
|
||||
for i in 0..n {
|
||||
let (a, b) = (p[i], p[(i + 1) % n]);
|
||||
if (a.y > q.y) != (b.y > q.y) && q.x < a.x + (q.y - a.y) / (b.y - a.y) * (b.x - a.x) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Distance outside (0 inside), approximate for polygons (to the edges).
|
||||
pub fn dist(&self, q: P) -> f64 {
|
||||
if self.inside(q) {
|
||||
return 0.0;
|
||||
}
|
||||
match self {
|
||||
GtFill::Disc(c, r) => q.dist(*c) - r,
|
||||
GtFill::Poly(p) => {
|
||||
let n = p.len();
|
||||
(0..n).map(|i| seg_dist(q, p[i], p[(i + 1) % n]).0).fold(f64::MAX, f64::min)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Scene {
|
||||
pub name: String,
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub strokes: Vec<GtStroke>,
|
||||
pub fills: Vec<GtFill>,
|
||||
pub junctions: Vec<(P, usize)>,
|
||||
pub corners: Vec<P>,
|
||||
pub wmax: f64,
|
||||
pub min_len: f64,
|
||||
/// Added to every coordinate (odd widths at integer positions put pixel centres exactly on
|
||||
/// the edge: ties that make coverage numbers meaningless).
|
||||
pub offset: P,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
fn new(name: &str, w: usize, h: usize, wmax: f64) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
w,
|
||||
h,
|
||||
strokes: vec![],
|
||||
fills: vec![],
|
||||
junctions: vec![],
|
||||
corners: vec![],
|
||||
wmax,
|
||||
min_len: 20.0,
|
||||
offset: P::new(0.0, 0.0),
|
||||
}
|
||||
}
|
||||
/// A polyline stroke of constant width.
|
||||
fn line(&mut self, pts: &[P], w: f64) {
|
||||
self.var(pts, &vec![w; pts.len()], false);
|
||||
}
|
||||
/// A polyline stroke with a width at each vertex (linear between), densified.
|
||||
fn var(&mut self, pts: &[P], ws: &[f64], closed: bool) {
|
||||
let pts: Vec<P> = pts.iter().map(|&p| p + self.offset).collect();
|
||||
let pts = &pts[..];
|
||||
let mut d = vec![];
|
||||
let mut dw = vec![];
|
||||
let n = pts.len();
|
||||
let segs = if closed { n } else { n.saturating_sub(1) };
|
||||
for i in 0..segs {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let (wa, wb) = (ws[i], ws[(i + 1) % n]);
|
||||
let k = ((a.dist(b) / 0.25).ceil() as usize).max(1);
|
||||
for j in 0..k {
|
||||
let t = j as f64 / k as f64;
|
||||
d.push(a.lerp(b, t));
|
||||
dw.push(wa + (wb - wa) * t);
|
||||
}
|
||||
}
|
||||
if !closed && let (Some(&l), Some(&lw)) = (pts.last(), ws.last()) {
|
||||
d.push(l);
|
||||
dw.push(lw);
|
||||
}
|
||||
self.strokes.push(GtStroke { pts: d, w: dw, closed });
|
||||
}
|
||||
fn curve(&mut self, f: impl Fn(f64) -> P, wf: impl Fn(f64) -> f64, n: usize, closed: bool) {
|
||||
let m = if closed { n } else { n + 1 };
|
||||
let pts: Vec<P> = (0..m).map(|k| f(k as f64 / n as f64)).collect();
|
||||
let ws: Vec<f64> = (0..m).map(|k| wf(k as f64 / n as f64)).collect();
|
||||
self.var(&pts, &ws, closed);
|
||||
}
|
||||
fn arc(&mut self, c: P, r: f64, a0: f64, a1: f64, w: f64) {
|
||||
let n = ((r * (a1 - a0).abs() / 0.5).ceil() as usize).max(8);
|
||||
self.curve(
|
||||
|t| {
|
||||
let a = a0 + (a1 - a0) * t;
|
||||
c + P::new(r * a.cos(), r * a.sin())
|
||||
},
|
||||
|_| w,
|
||||
n,
|
||||
false,
|
||||
);
|
||||
}
|
||||
fn ring(&mut self, c: P, r: f64, w: f64) {
|
||||
let n = ((r * std::f64::consts::TAU / 0.5).ceil() as usize).max(16);
|
||||
self.curve(
|
||||
|t| {
|
||||
let a = std::f64::consts::TAU * t;
|
||||
c + P::new(r * a.cos(), r * a.sin())
|
||||
},
|
||||
|_| w,
|
||||
n,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ink coverage (8 × 8 samples per pixel).
|
||||
pub fn rasterise(&self) -> Vec<f32> {
|
||||
self.rasterise_at(8)
|
||||
}
|
||||
/// Exact shape point-sampled at pixel centres (the reference for coverage).
|
||||
pub fn exact(&self) -> Vec<bool> {
|
||||
self.rasterise_at(1).into_iter().map(|c| c >= 1.0).collect()
|
||||
}
|
||||
fn rasterise_at(&self, ss: usize) -> Vec<f32> {
|
||||
let (sw, sh) = (self.w * ss, self.h * ss);
|
||||
let mut sub = vec![false; sw * sh];
|
||||
let at = |x: usize, y: usize| P::new((x as f64 + 0.5) / ss as f64, (y as f64 + 0.5) / ss as f64);
|
||||
for s in &self.strokes {
|
||||
let n = s.pts.len();
|
||||
let segs = if s.closed { n } else { n.saturating_sub(1) };
|
||||
let mut stamp = |a: P, b: P, ra: f64, rb: f64| {
|
||||
let r = ra.max(rb);
|
||||
let x0 = (((a.x.min(b.x) - r) * ss as f64).floor().max(0.0)) as usize;
|
||||
let y0 = (((a.y.min(b.y) - r) * ss as f64).floor().max(0.0)) as usize;
|
||||
let x1 = ((((a.x.max(b.x) + r) * ss as f64).ceil().max(0.0)) as usize).min(sw);
|
||||
let y1 = ((((a.y.max(b.y) + r) * ss as f64).ceil().max(0.0)) as usize).min(sh);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let q = at(x, y);
|
||||
let (d, t) = seg_dist(q, a, b);
|
||||
if d <= ra + (rb - ra) * t {
|
||||
sub[y * sw + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if n == 1 {
|
||||
stamp(s.pts[0], s.pts[0], s.w[0] / 2.0, s.w[0] / 2.0);
|
||||
}
|
||||
for i in 0..segs {
|
||||
let j = (i + 1) % n;
|
||||
stamp(s.pts[i], s.pts[j], s.w[i] / 2.0, s.w[j] / 2.0);
|
||||
}
|
||||
}
|
||||
for f in &self.fills {
|
||||
for y in 0..sh {
|
||||
for x in 0..sw {
|
||||
if !sub[y * sw + x] && f.inside(at(x, y)) {
|
||||
sub[y * sw + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; self.w * self.h];
|
||||
for y in 0..self.h {
|
||||
for x in 0..self.w {
|
||||
let mut c = 0;
|
||||
for yy in 0..ss {
|
||||
for xx in 0..ss {
|
||||
c += usize::from(sub[(y * ss + yy) * sw + x * ss + xx]);
|
||||
}
|
||||
}
|
||||
out[y * self.w + x] = c as f32 / (ss * ss) as f32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn pol(c: P, r: f64, deg: f64) -> P {
|
||||
let a = deg.to_radians();
|
||||
c + P::new(r * a.cos(), r * a.sin())
|
||||
}
|
||||
|
||||
/// 24 straight lines of width `w`, at 0°–172.5° in 7.5° steps, centres at varied sub-pixel phases.
|
||||
pub fn lines(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("lines w{w}"), 480, 320, 13.0);
|
||||
for k in 0..24 {
|
||||
let (cx, cy) = ((k % 6) as f64 * 80.0 + 40.0 + 0.13 * k as f64 % 1.0, (k / 6) as f64 * 80.0 + 40.0 + 0.29 * k as f64 % 1.0);
|
||||
let c = P::new(cx, cy);
|
||||
let a = 7.5 * k as f64;
|
||||
s.line(&[pol(c, 25.0, a + 180.0), pol(c, 25.0, a)], w);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn curves(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("curves w{w}"), 400, 300, 13.0);
|
||||
// Sine waves.
|
||||
s.curve(|t| P::new(30.0 + 340.0 * t, 50.0 + 20.0 * (t * 12.0).sin()), |_| w, 400, false);
|
||||
s.curve(|t| P::new(30.0 + 340.0 * t, 110.0 + 10.0 * (t * 25.0).sin()), |_| w, 600, false);
|
||||
// Cubic S-curve.
|
||||
let cubic = |t: f64| {
|
||||
let (p0, p1, p2, p3) = (P::new(40.0, 280.0), P::new(60.0, 140.0), P::new(200.0, 300.0), P::new(190.0, 160.0));
|
||||
let u = 1.0 - t;
|
||||
p0 * (u * u * u) + p1 * (3.0 * u * u * t) + p2 * (3.0 * u * t * t) + p3 * (t * t * t)
|
||||
};
|
||||
s.curve(cubic, |_| w, 300, false);
|
||||
// Spiral.
|
||||
s.curve(
|
||||
|t| {
|
||||
let a = t * 4.0 * std::f64::consts::PI;
|
||||
P::new(300.0, 225.0) + P::new(a.cos(), a.sin()) * (12.0 + 50.0 * t)
|
||||
},
|
||||
|_| w,
|
||||
800,
|
||||
false,
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn circles() -> Scene {
|
||||
let mut s = Scene::new("circles", 520, 260, 13.0);
|
||||
let mut x = 50.0;
|
||||
for (r, w) in [(8.0, 1.0), (12.0, 2.0), (18.0, 3.0), (25.0, 4.0), (35.0, 6.0), (45.0, 8.0)] {
|
||||
s.ring(P::new(x, 70.0 + 0.3 * r % 1.0), r, w);
|
||||
s.ring(P::new(x + 10.0, 190.0), r * 1.2, (w * 0.5_f64).max(1.0));
|
||||
x += 2.0 * r + 35.0;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn junctions(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("junctions w{w}"), 520, 220, 13.0);
|
||||
s.offset = P::new(0.27, 0.41);
|
||||
// T.
|
||||
s.line(&[P::new(20.0, 40.0), P::new(100.0, 40.0)], w);
|
||||
s.line(&[P::new(60.0, 40.0), P::new(60.0, 100.0)], w);
|
||||
s.junctions.push((P::new(60.0, 40.0), 3));
|
||||
// Y (120°).
|
||||
let c = P::new(180.0, 70.0);
|
||||
for a in [-90.0, 30.0, 150.0] {
|
||||
s.line(&[c, pol(c, 40.0, a)], w);
|
||||
}
|
||||
s.junctions.push((c, 3));
|
||||
// X (90°).
|
||||
let c = P::new(300.0, 70.0);
|
||||
s.line(&[pol(c, 40.0, 45.0), pol(c, 40.0, 225.0)], w);
|
||||
s.line(&[pol(c, 40.0, 135.0), pol(c, 40.0, 315.0)], w);
|
||||
s.junctions.push((c, 4));
|
||||
// Oblique T (stem at 50°).
|
||||
s.line(&[P::new(380.0, 40.0), P::new(500.0, 40.0)], w);
|
||||
s.line(&[P::new(430.0, 40.0), pol(P::new(430.0, 40.0), 70.0, 50.0)], w);
|
||||
s.junctions.push((P::new(430.0, 40.0), 3));
|
||||
// Thin stem on a thick bar.
|
||||
let bar = (w * 2.0).min(12.0);
|
||||
s.line(&[P::new(20.0, 160.0), P::new(140.0, 160.0)], bar);
|
||||
s.line(&[P::new(80.0, 160.0), P::new(80.0, 215.0)], (w * 0.75).max(1.0));
|
||||
s.junctions.push((P::new(80.0, 160.0), 3));
|
||||
// Narrow Y (arms 40° apart).
|
||||
let c = P::new(230.0, 150.0);
|
||||
s.line(&[c, pol(c, 60.0, 180.0)], w);
|
||||
s.line(&[c, pol(c, 50.0, -20.0)], w);
|
||||
s.line(&[c, pol(c, 50.0, 20.0)], w);
|
||||
s.junctions.push((c, 3));
|
||||
// Asterisk-free "K": arms meeting the stem at one point (degree 4).
|
||||
let c = P::new(400.0, 165.0);
|
||||
s.line(&[P::new(400.0, 120.0), P::new(400.0, 210.0)], w);
|
||||
s.line(&[c, pol(c, 50.0, -40.0)], w);
|
||||
s.line(&[c, pol(c, 50.0, 40.0)], w);
|
||||
s.junctions.push((c, 4));
|
||||
shifted(s)
|
||||
}
|
||||
|
||||
pub fn crossings(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("crossings w{w}"), 560, 140, 13.0);
|
||||
s.offset = P::new(0.27, 0.41);
|
||||
for (k, ang) in [20.0, 30.0, 45.0, 60.0, 90.0].into_iter().enumerate() {
|
||||
let c = P::new(60.0 + 110.0 * k as f64, 70.0);
|
||||
s.line(&[pol(c, 50.0, 180.0 - ang / 2.0), pol(c, 50.0, -ang / 2.0)], w);
|
||||
s.line(&[pol(c, 50.0, 180.0 + ang / 2.0), pol(c, 50.0, ang / 2.0)], w);
|
||||
s.junctions.push((c, 4));
|
||||
}
|
||||
shifted(s)
|
||||
}
|
||||
|
||||
pub fn dashed(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("dashed w{w}"), 400, 260, 13.0);
|
||||
s.min_len = 0.0;
|
||||
for (k, (dash, gap, ang)) in [(12.0, 4.0, 0.0), (6.0, 3.0, 15.0), (10.0, 4.0, 37.0), (16.0, 6.0, 90.0), (8.0, 5.0, 135.0)].into_iter().enumerate()
|
||||
{
|
||||
let c = P::new(80.0 + 120.0 * (k % 3) as f64, 70.0 + 120.0 * (k / 3) as f64);
|
||||
let a: f64 = f64::to_radians(ang);
|
||||
let dir = P::new(a.cos(), a.sin());
|
||||
let mut t = -50.0;
|
||||
while t + dash <= 50.0 {
|
||||
s.line(&[c + dir * t, c + dir * (t + dash)], w);
|
||||
// The visible gap is `gap`: the round caps reach w/2 past each dash's ends.
|
||||
t += dash + gap + w;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Letter-like strokes drawn from lines and arcs (no fonts).
|
||||
pub fn letters(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("letters w{w}"), 520, 120, 13.0);
|
||||
s.offset = P::new(0.27, 0.41);
|
||||
// A: two legs meeting at the apex (a corner), crossbar (two T's).
|
||||
let (l, apex, r) = (P::new(20.0, 100.0), P::new(50.0, 20.0), P::new(80.0, 100.0));
|
||||
s.line(&[l, apex, r], w);
|
||||
let (bl, br) = (l.lerp(apex, 0.4), r.lerp(apex, 0.4));
|
||||
s.line(&[bl, br], w);
|
||||
s.junctions.push((bl, 3));
|
||||
s.junctions.push((br, 3));
|
||||
s.corners.push(apex);
|
||||
// H.
|
||||
s.line(&[P::new(110.0, 20.0), P::new(110.0, 100.0)], w);
|
||||
s.line(&[P::new(160.0, 20.0), P::new(160.0, 100.0)], w);
|
||||
s.line(&[P::new(110.0, 60.0), P::new(160.0, 60.0)], w);
|
||||
s.junctions.push((P::new(110.0, 60.0), 3));
|
||||
s.junctions.push((P::new(160.0, 60.0), 3));
|
||||
// e: bar across a circle that opens at the lower right.
|
||||
let c = P::new(215.0, 65.0);
|
||||
s.arc(c, 25.0, 0.0, -std::f64::consts::PI * 1.75, w);
|
||||
s.line(&[pol(c, 25.0, 180.0), pol(c, 25.0, 0.0)], w);
|
||||
s.junctions.push((pol(c, 25.0, 180.0), 3));
|
||||
s.corners.push(pol(c, 25.0, 0.0));
|
||||
// k.
|
||||
let c = P::new(275.0, 75.0);
|
||||
s.line(&[P::new(275.0, 15.0), P::new(275.0, 105.0)], w);
|
||||
s.line(&[c, P::new(310.0, 45.0)], w);
|
||||
s.line(&[c, P::new(310.0, 105.0)], w);
|
||||
s.junctions.push((c, 4));
|
||||
// x.
|
||||
s.line(&[P::new(330.0, 40.0), P::new(370.0, 100.0)], w);
|
||||
s.line(&[P::new(370.0, 40.0), P::new(330.0, 100.0)], w);
|
||||
s.junctions.push((P::new(350.0, 70.0), 4));
|
||||
// s: two arcs.
|
||||
s.arc(P::new(405.0, 50.0), 15.0, -0.2, -std::f64::consts::PI * 1.5, w);
|
||||
s.arc(P::new(405.0, 80.0), 15.0, -std::f64::consts::PI * 0.5, std::f64::consts::PI * 0.8, w);
|
||||
s.corners.push(P::new(405.0, 65.0));
|
||||
// o.
|
||||
s.ring(P::new(475.0, 65.0), 25.0, w);
|
||||
shifted(s)
|
||||
}
|
||||
|
||||
/// Thin strokes and wide fills, with strokes running into fills; Stroke width 6.
|
||||
pub fn mix() -> Scene {
|
||||
let mut s = Scene::new("mix", 480, 300, 6.0);
|
||||
s.fills.push(GtFill::Disc(P::new(100.0, 100.0), 30.0));
|
||||
s.fills.push(GtFill::Poly(vec![P::new(260.0, 60.0), P::new(340.0, 60.0), P::new(340.0, 110.0), P::new(260.0, 110.0)]));
|
||||
s.fills.push(GtFill::Disc(P::new(400.0, 220.0), 22.0));
|
||||
// Lines into the disc and the rectangle.
|
||||
s.line(&[P::new(20.0, 200.0), P::new(80.0, 120.0)], 3.0);
|
||||
s.line(&[P::new(130.0, 100.0), P::new(260.0, 85.0)], 2.0);
|
||||
s.line(&[P::new(300.0, 110.0), P::new(300.0, 200.0), P::new(380.0, 210.0)], 4.0);
|
||||
// Free strokes.
|
||||
s.line(&[P::new(40.0, 260.0), P::new(250.0, 280.0)], 3.0);
|
||||
s.curve(|t| P::new(150.0 + 120.0 * t, 200.0 + 25.0 * (t * 7.0).sin()), |_| 2.0, 200, false);
|
||||
s.line(&[P::new(420.0, 30.0), P::new(460.0, 150.0)], 5.0);
|
||||
s
|
||||
}
|
||||
|
||||
/// Strokes whose width varies: tapers, a lens, a swelling brush curve; Stroke width 12.
|
||||
pub fn varying() -> Scene {
|
||||
let mut s = Scene::new("varying", 480, 300, 12.0);
|
||||
s.curve(|t| P::new(30.0 + 200.0 * t, 40.0), |t| 2.0 + 8.0 * t, 200, false);
|
||||
s.curve(|t| P::new(260.0 + 200.0 * t, 40.0 + 30.0 * t), |t| 2.0 + 8.0 * (std::f64::consts::PI * t).sin(), 200, false);
|
||||
s.curve(|t| P::new(30.0 + 420.0 * t, 150.0 + 40.0 * (t * 6.0).sin()), |t| 5.5 + 3.0 * (t * 9.0).sin(), 600, false);
|
||||
s.curve(
|
||||
|t| {
|
||||
let a = t * 1.6 * std::f64::consts::PI;
|
||||
P::new(120.0, 250.0) + P::new(a.cos(), a.sin()) * 35.0
|
||||
},
|
||||
|t| 2.0 + 9.0 * t,
|
||||
300,
|
||||
false,
|
||||
);
|
||||
s.curve(|t| P::new(250.0 + 200.0 * t, 260.0 - 50.0 * t * t), |t| 9.0 - 6.0 * t, 200, false);
|
||||
s
|
||||
}
|
||||
|
||||
/// Shifted copies of the integer-placed scenes' junctions and corners (strokes shift in `var`).
|
||||
fn shifted(mut s: Scene) -> Scene {
|
||||
let o = s.offset;
|
||||
for j in &mut s.junctions {
|
||||
j.0 = j.0 + o;
|
||||
}
|
||||
for c in &mut s.corners {
|
||||
*c = *c + o;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn all() -> Vec<Scene> {
|
||||
let mut v = vec![];
|
||||
for w in [1.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0] {
|
||||
v.push(lines(w));
|
||||
}
|
||||
for w in [2.0, 4.0, 8.0] {
|
||||
v.push(curves(w));
|
||||
}
|
||||
v.push(circles());
|
||||
for w in [2.0, 4.0, 8.0] {
|
||||
v.push(junctions(w));
|
||||
}
|
||||
for w in [3.0, 6.0] {
|
||||
v.push(crossings(w));
|
||||
}
|
||||
for w in [2.0, 4.0] {
|
||||
v.push(dashed(w));
|
||||
}
|
||||
for w in [3.0, 5.0] {
|
||||
v.push(letters(w));
|
||||
}
|
||||
v.push(mix());
|
||||
v.push(varying());
|
||||
v
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! ```
|
||||
//!
|
||||
//! `TRACE_QUALITY_MP` (default `1,4,16`) sets the megapixel sizes of the cost table.
|
||||
//! `TRACE_QUALITY_CLASS=centre-lines` selects only the code-drawn centre-line report.
|
||||
//!
|
||||
//! A yardstick: `TRACE_QUALITY_IMAGES=dir` also writes the drawings there as PNG files, and
|
||||
//! `TRACE_QUALITY_YARDSTICK=dir` measures the SVG files of the same names found there (what another
|
||||
@@ -15,6 +16,9 @@
|
||||
// The peak-memory measure needs a counting global allocator.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
mod centre_geom;
|
||||
mod centre_lines;
|
||||
mod centre_scenes;
|
||||
mod inputs;
|
||||
mod measure;
|
||||
mod raster;
|
||||
@@ -29,7 +33,7 @@ use measure::{Metrics, measure};
|
||||
use vectorcraft_trace::{Raster, TraceParams, TraceResult, TracedPath, presets, trace_within};
|
||||
|
||||
/// The engine's limit (`MAX_TRACE_ANCHORS`): a trace past it is refused.
|
||||
const MAX_ANCHORS: usize = 2_000_000;
|
||||
const MAX_ANCHORS: usize = 8_000_000;
|
||||
|
||||
struct Counting;
|
||||
static LIVE: AtomicUsize = AtomicUsize::new(0);
|
||||
@@ -239,7 +243,7 @@ fn cost(img: &Raster, params: &TraceParams) -> String {
|
||||
Ok(res) => format!("{secs:.2} s, {} paths, {} anchors, {mb:.0} MB", res.paths.len(), res.anchor_count()),
|
||||
Err(e) => {
|
||||
let why = e.to_string();
|
||||
let why = if why.contains("anchor points") { "over 2,000,000 anchors" } else { "not flat art" };
|
||||
let why = if why.contains("anchor points") { "over 8,000,000 anchors" } else { "not flat art" };
|
||||
format!("refused after {secs:.2} s ({why}), {mb:.0} MB")
|
||||
}
|
||||
}
|
||||
@@ -337,7 +341,7 @@ fn report() -> String {
|
||||
.map(|mp| (mp.sqrt() * 1024.0).round() as u32)
|
||||
.collect();
|
||||
let (photo, badge) = (inputs.iter().find(|i| i.name == "photo-like").unwrap(), inputs.iter().find(|i| i.name == "four-colour badge").unwrap());
|
||||
md += "\n## 5. Cost\n\nOne trace at a time on this machine (all cores); memory is the peak allocated above the image itself. A trace of more than 2,000,000 anchors is refused, as in the app.\n\n| preset | image | megapixels | result |\n|:--|:--|--:|:--|\n";
|
||||
md += "\n## 5. Cost\n\nOne trace at a time on this machine (all cores); memory is the peak allocated above the image itself. A trace of more than 8,000,000 anchors is refused, as in the app.\n\n| preset | image | megapixels | result |\n|:--|:--|--:|:--|\n";
|
||||
for size in sizes {
|
||||
let n = size as usize;
|
||||
let images = [("photo-like", photo.draw(size, Variant::Clean)), ("four-colour badge", raster::render(&badge.scaled(size), badge.bg, n, n))];
|
||||
@@ -347,12 +351,23 @@ fn report() -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
md += ¢re_lines::report();
|
||||
md
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "slow: measures every preset and writes the quality report (run with --release)"]
|
||||
fn quality_report() {
|
||||
// One ignored test owns the allocator's cost measurements. Selecting this class
|
||||
// here avoids a second ignored test allocating concurrently with those measurements.
|
||||
if std::env::var("TRACE_QUALITY_CLASS").as_deref() == Ok("centre-lines") {
|
||||
let md = centre_lines::report();
|
||||
println!("{md}");
|
||||
if let Ok(path) = std::env::var("TRACE_QUALITY_REPORT") {
|
||||
std::fs::write(path, md).unwrap();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let md = report();
|
||||
println!("{md}");
|
||||
// Gradients finds none in art that is flat colour, however it is degraded.
|
||||
|
||||
@@ -56,7 +56,7 @@ pub fn fidelity(src: &Raster, out: &Raster) -> (f64, f64) {
|
||||
}
|
||||
|
||||
/// A traced path as the shape it paints: stroked paths become their stroke's outline.
|
||||
fn painted(tp: &TracedPath) -> BezPath {
|
||||
pub(super) fn painted(tp: &TracedPath) -> BezPath {
|
||||
let bez = tp.path.to_bezpath();
|
||||
match tp.stroke {
|
||||
Some(width) => {
|
||||
|
||||
@@ -140,7 +140,7 @@ fn from_rgb(w: usize, h: usize, buf: &[[f32; 3]]) -> Raster {
|
||||
rgba.extend(p.map(|v| v.round().clamp(0.0, 255.0) as u8));
|
||||
rgba.push(255);
|
||||
}
|
||||
Raster::new(w as u32, h as u32, rgba)
|
||||
Raster::new(w as u32, h as u32, rgba).unwrap()
|
||||
}
|
||||
|
||||
/// A deterministic hash of a pixel position and a salt, as a value in `-1.0..1.0`.
|
||||
|
||||
@@ -41,6 +41,8 @@ web-time = "1"
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
# A variable font built in code (no font file is committed).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
wat = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -665,7 +665,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
| Cursor::Scissors
|
||||
| Cursor::ScissorsNotAllowed
|
||||
| Cursor::MeshAdd
|
||||
| Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
| Cursor::PuppetPin
|
||||
| Cursor::LoadedText => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2127,8 +2128,9 @@ impl HiddenMarks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text threads of the selected frames: a line from each frame's out port (bottom right) to the
|
||||
/// next frame's in port (top left), like Illustrator's thread indicators.
|
||||
/// Text threads of the selected frames: a line from each frame's out port (on its right side,
|
||||
/// just above the bottom right, where the tools draw the red one) to the next frame's in port (top
|
||||
/// left), like Illustrator's thread indicators.
|
||||
fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
for thread in &st.doc.text_threads {
|
||||
@@ -2138,7 +2140,8 @@ fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let color = c32(st.doc.layer_color(thread[0]));
|
||||
let frames: Vec<vectorcraft_geom::Rect> = thread.iter().filter_map(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds())).collect();
|
||||
for w in frames.windows(2) {
|
||||
let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1)), xf.to_screen(Point::new(w[1].x0, w[1].y0)));
|
||||
let rise = (vectorcraft_tools::text::PORT_RISE_PX / xf.zoom.max(1e-9)).min(w[0].height() / 2.0);
|
||||
let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1 - rise)), xf.to_screen(Point::new(w[1].x0, w[1].y0)));
|
||||
p.line_segment([out, inp], Stroke::new(1.0, color));
|
||||
for port in [out, inp] {
|
||||
p.rect_filled(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Color32::WHITE);
|
||||
@@ -2876,11 +2879,17 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE));
|
||||
items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage"));
|
||||
}
|
||||
// Direct-selected anchors take the place of a path's Offset Path and Simplify.
|
||||
// Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar
|
||||
// offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does
|
||||
// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
|
||||
// These run the same commands as the Control bar's anchor buttons.
|
||||
let anchors = !st.selection.anchors.is_empty();
|
||||
if anchors {
|
||||
items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors"));
|
||||
items.push((tl!("Connect"), "dc-join", "path.join"));
|
||||
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors"));
|
||||
items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER));
|
||||
items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH));
|
||||
}
|
||||
if n > 1 {
|
||||
items.push((tl!("Group"), "group", "object.group"));
|
||||
@@ -3028,6 +3037,9 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
Some((TASK_BAR_ARTBOARD_OPTIONS, _)) => crate::menus::invoke(app, "tool.options", json!({"tool": "artboard"})),
|
||||
Some((TASK_BAR_DUPLICATE_ARTBOARDS, _)) => crate::panels::artboards::duplicate_picked(app),
|
||||
Some((TASK_BAR_REARRANGE_ARTBOARDS, _)) => crate::panels::artboards::open_rearrange(app),
|
||||
// Corner and Smooth convert the direct-selected anchors, as the Control bar's buttons do.
|
||||
Some((TASK_BAR_TO_CORNER, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "corner"})),
|
||||
Some((TASK_BAR_TO_SMOOTH, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "smooth"})),
|
||||
Some((c, p)) => crate::menus::invoke(app, c, p),
|
||||
None => {}
|
||||
}
|
||||
@@ -3041,6 +3053,10 @@ const TASK_BAR_ARTBOARD_OPTIONS: &str = "taskBar.artboardOptions";
|
||||
const TASK_BAR_DUPLICATE_ARTBOARDS: &str = "taskBar.duplicateArtboards";
|
||||
/// The Contextual Task Bar's Rearrange All button with the Artboard tool.
|
||||
const TASK_BAR_REARRANGE_ARTBOARDS: &str = "taskBar.rearrangeArtboards";
|
||||
/// The Contextual Task Bar's Corner button for direct-selected anchors.
|
||||
const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner";
|
||||
/// The Contextual Task Bar's Smooth button for direct-selected anchors.
|
||||
const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -6606,6 +6622,49 @@ mod tests {
|
||||
assert_eq!(boards(&app), before, "one undo step");
|
||||
}
|
||||
|
||||
/// With anchors direct-selected, the Contextual Task Bar offers Remove Anchor Points, Connect,
|
||||
/// Cut Path, Corner and Smooth. Smooth and Corner convert the selected anchor, Cut Path opens
|
||||
/// the path there and Connect closes it again, as the Control bar's anchor buttons do.
|
||||
#[test]
|
||||
fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
app.select_tool("directSelection");
|
||||
app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
|
||||
assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth", "Duplicate"]);
|
||||
let press = |app: &mut VectorcraftApp, label: &str| {
|
||||
let labels = task_bar_labels(app, &ctx);
|
||||
let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center();
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||||
frame(app, &ctx, vec![button(true)]);
|
||||
frame(app, &ctx, vec![button(false)]);
|
||||
};
|
||||
let subpath = |app: &VectorcraftApp| {
|
||||
app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone()
|
||||
};
|
||||
let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone();
|
||||
press(&mut app, "Smooth");
|
||||
let a = subpath(&app).anchors[1];
|
||||
assert!(a.has_in() && a.has_out(), "smooth: {a:?}");
|
||||
assert_eq!(last_undo(&app), "Convert Anchor Points");
|
||||
press(&mut app, "Corner");
|
||||
let a = subpath(&app).anchors[1];
|
||||
assert!(!a.has_in() && !a.has_out(), "corner: {a:?}");
|
||||
press(&mut app, "Cut Path");
|
||||
assert_eq!(last_undo(&app), "Cut Path");
|
||||
let sp = subpath(&app);
|
||||
assert!(!sp.closed && sp.anchors.len() == 5, "the rectangle opens at the anchor: {sp:?}");
|
||||
press(&mut app, "Connect");
|
||||
assert_eq!(last_undo(&app), "Join");
|
||||
let sp = subpath(&app);
|
||||
assert!(sp.closed && sp.anchors.len() == 4, "the ends join again: {sp:?}");
|
||||
}
|
||||
|
||||
/// The Contextual Task Bar's handle drags it: unpinned it keeps that offset as it follows the
|
||||
/// selection, pinned it stays put when the selection changes, unpinned again it follows from
|
||||
/// there, and Reset Bar Position puts it back under the selection. It never leaves the canvas,
|
||||
|
||||
@@ -409,7 +409,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
|
||||
|
||||
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
|
||||
#[cfg(test)]
|
||||
const GLYPHS: [Cursor; 74] = [
|
||||
const GLYPHS: [Cursor; 75] = [
|
||||
Cursor::Hand,
|
||||
Cursor::HandGrab,
|
||||
Cursor::ZoomIn,
|
||||
@@ -456,6 +456,7 @@ const GLYPHS: [Cursor; 74] = [
|
||||
Cursor::BlendAnchor,
|
||||
Cursor::PathBracket,
|
||||
Cursor::TypeWidget,
|
||||
Cursor::LoadedText,
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
Cursor::Lasso,
|
||||
|
||||
@@ -158,6 +158,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
free_distort::fields(app, ui, d);
|
||||
values_changed(d)
|
||||
}
|
||||
None if id == vectorcraft_effects::RASTERIZE => {
|
||||
super::raster_effects::effect_fields(app, ui, d);
|
||||
values_changed(d)
|
||||
}
|
||||
None => {
|
||||
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
|
||||
let unit = app.session.general_unit();
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! anti-alias, clipping mask, room around the art and spot colour options.
|
||||
//!
|
||||
//! Fields: what `document.rasterEffectsSettings` reports (and takes back). OK runs it with them:
|
||||
//! one undo step. On an error the dialog stays open.
|
||||
//! one undo step. On an error the dialog stays open. [`effect_fields`] lays out the options of
|
||||
//! Effect › Rasterize… with the same widgets.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -94,6 +95,67 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The options of Effect › Rasterize… in its effect dialog, with this dialog's widgets: Color
|
||||
/// Model, Resolution (a preset, Use Document Raster Effects Resolution or Other) and Background,
|
||||
/// then an Anti-aliasing menu, Create Clipping Mask and Add … Around Object.
|
||||
pub(super) fn effect_fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let own = if app.session.active().is_some_and(|s| s.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk) { "CMYK" } else { "RGB" };
|
||||
choice(ui, d, "colorModel", tl!("Color Model:"), &[("document", own), ("grayscale", tl!("Grayscale")), ("bitmap", tl!("Bitmap"))]);
|
||||
ui.add_space(4.0);
|
||||
let (ppi, document) = (d.f64("resolution", 72.0), d.bool("useDocumentResolution"));
|
||||
let preset = RESOLUTIONS.iter().position(|(p, _)| (*p - ppi).abs() < 1e-9);
|
||||
let mut labels: Vec<&str> = RESOLUTIONS.iter().map(|(_, l)| tl!(l)).collect();
|
||||
labels.push(tl!("Use Document Raster Effects Resolution"));
|
||||
labels.push(tl!("Other"));
|
||||
let shown = if document { RESOLUTIONS.len() } else { preset.unwrap_or(RESOLUTIONS.len() + 1) };
|
||||
widgets::label_row(ui, tl!("Resolution:"), LABEL, |ui| {
|
||||
if let Some(i) = widgets::dropdown(ui, "rasterize-resolution", labels.get(shown).copied().unwrap_or_default(), &labels, 260.0) {
|
||||
d.fields.insert("useDocumentResolution".into(), json!(i == RESOLUTIONS.len()));
|
||||
if let Some((p, _)) = RESOLUTIONS.get(i) {
|
||||
d.fields.insert("resolution".into(), json!(p));
|
||||
} else if i > RESOLUTIONS.len() && preset.is_some() {
|
||||
// Other moves the value 1 ppi off the preset, so its own field shows.
|
||||
d.fields.insert("resolution".into(), json!(ppi.round() + 1.0));
|
||||
}
|
||||
}
|
||||
if !document
|
||||
&& preset.is_none()
|
||||
&& let Some(v) = widgets::plain_field(ui, "rasterize-ppi", ppi, " ppi", 0, 80.0)
|
||||
{
|
||||
d.fields.insert("resolution".into(), json!(v));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
widgets::label_row(ui, tl!("Background:"), LABEL, |ui| {
|
||||
let cur = d.str("background");
|
||||
for (id, label) in [("white", tl!("White")), ("transparent", tl!("Transparent"))] {
|
||||
if widgets::radio(ui, label, cur == id, true) {
|
||||
d.fields.insert("background".into(), json!(id));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, tl!("Options"));
|
||||
widgets::label_row(ui, tl!("Anti-aliasing:"), LABEL, |ui| {
|
||||
use vectorcraft_render::AntiAlias;
|
||||
let aa = AntiAlias::from_id(&d.str("antiAlias")).unwrap_or(AntiAlias::Art);
|
||||
if let Some(i) = widgets::dropdown(ui, "rasterize-aa", aa.label(), &AntiAlias::ALL.map(AntiAlias::label), 170.0) {
|
||||
d.fields.insert("antiAlias".into(), json!(AntiAlias::ALL.get(i).copied().unwrap_or(AntiAlias::Art).id()));
|
||||
}
|
||||
});
|
||||
check(ui, d, "clippingMask", tl!("Create Clipping Mask"));
|
||||
let unit = app.session.general_unit();
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Add")).color(t.text));
|
||||
if let Some(v) = widgets::num_field(ui, "rasterize-add-around", Some(d.f64("addAround", 0.0)), unit, 80.0) {
|
||||
d.fields.insert("addAround".into(), json!(v));
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Around Object")).color(t.text));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -149,4 +211,32 @@ mod tests {
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Image(_)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_effect_dialog_previews_and_ok_applies_its_options_as_one_undo_step() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
let steps = app.session.active().unwrap().history.undo.len();
|
||||
crate::menus::invoke(&mut app, "effect.dialog", json!({"effect": "rasterize"}));
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.fields["useDocumentResolution"].as_bool()), ("effect", Some(true)));
|
||||
frame(&mut app);
|
||||
assert!(app.session.in_interaction(), "previews");
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
for (k, v) in
|
||||
[("useDocumentResolution", json!(false)), ("resolution", json!(200)), ("background", json!("white")), ("antiAlias", json!("none"))]
|
||||
{
|
||||
d.fields.insert(k.into(), v);
|
||||
}
|
||||
// Other resolutions show their own field.
|
||||
frame(&mut app);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
let n = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap();
|
||||
let (e, p) = (&n.appearance.effects[0], &n.appearance.effects[0].params);
|
||||
assert_eq!((e.id.as_str(), &p["resolution"], &p["background"], &p["antiAlias"]), ("rasterize", &json!(200), &json!("white"), &json!("none")));
|
||||
assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "the rectangle stays a path");
|
||||
assert_eq!(st.history.undo.len(), steps + 1);
|
||||
}
|
||||
}
|
||||
@@ -305,12 +305,20 @@ pub(crate) fn panel_tabs(
|
||||
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
|
||||
/// while the dock is collapsed. Dragging its title floats it.
|
||||
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some((id, label)) = app.ui.open_panel.as_deref().and_then(|p| crate::state::all_panels().find(|(q, _)| *q == p)) else { return };
|
||||
let open = app.ui.open_panel.as_deref();
|
||||
let Some(id) =
|
||||
open.and_then(|p| crate::state::all_panels().map(|(q, _)| q).find(|q| *q == p).or_else(|| crate::panels::library_panel::intern(p)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let tab = DockTab::from_id(id);
|
||||
// Expanded, the group shows its panels in the dock itself; a floating panel shows in its group.
|
||||
if tab.is_some() && !app.ui.dock_collapsed || floating::group_of(&app.ui, id).is_some() {
|
||||
return;
|
||||
}
|
||||
// A library panel (in a collapsed dock) is titled by its library's name.
|
||||
let label = crate::panels::library_panel::tab_label(app, id)
|
||||
.unwrap_or_else(|| crate::state::all_panels().find(|(q, _)| *q == id).map_or(id, |(_, label)| tl!(label)).to_string());
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
// Pinned by its right edge, 6 points left of the icon column wherever the dock's width (or its
|
||||
@@ -338,12 +346,10 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.set_width(width);
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
|
||||
let title = Rect::from_min_size(
|
||||
strip.min,
|
||||
vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
|
||||
);
|
||||
let title =
|
||||
Rect::from_min_size(strip.min, vec2(ui.painter().layout_no_wrap(label.clone(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0));
|
||||
ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
|
||||
ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
|
||||
ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, &label, theme::semibold(12.0), t.text);
|
||||
let close = Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
|
||||
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
|
||||
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
|
||||
|
||||
@@ -122,9 +122,15 @@ pub(crate) fn group_rect_id(first: &str) -> Id {
|
||||
Id::new(("floating-group-rect", first))
|
||||
}
|
||||
|
||||
/// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel.
|
||||
/// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel (a
|
||||
/// library panel's label is its id: [`label`] gives its library's name).
|
||||
fn panel(id: &str) -> Option<(&'static str, &'static str)> {
|
||||
all_panels().find(|(p, _)| *p == id)
|
||||
all_panels().find(|(p, _)| *p == id).or_else(|| crate::panels::library_panel::intern(id).map(|p| (p, p)))
|
||||
}
|
||||
|
||||
/// Panel `id`'s tab label: its English label, or a library panel's library name.
|
||||
fn label(app: &VectorcraftApp, id: &str) -> String {
|
||||
crate::panels::library_panel::tab_label(app, id).unwrap_or_else(|| panel(id).map_or(id, |(_, label)| label).to_string())
|
||||
}
|
||||
|
||||
/// The floating group holding panel `id`.
|
||||
@@ -816,10 +822,7 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action
|
||||
let tabs_width: f32 = g
|
||||
.ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let label = panel(id).map_or(*id, |(_, label)| label);
|
||||
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0
|
||||
})
|
||||
.map(|id| ui.painter().layout_no_wrap(tl!(&label(app, id)).to_string(), theme::semibold(12.0), t.text).size().x + 24.0)
|
||||
.sum();
|
||||
g.ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max)
|
||||
})
|
||||
@@ -882,7 +885,8 @@ fn draw_groups(
|
||||
let lone = g.ids.len() == 1;
|
||||
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
|
||||
let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom()));
|
||||
let names: Vec<_> = g.ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect();
|
||||
let labels: Vec<String> = g.ids.iter().map(|id| label(app, id)).collect();
|
||||
let names: Vec<_> = g.ids.iter().zip(&labels).map(|(&id, label)| (id, label.as_str())).collect();
|
||||
let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0);
|
||||
let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false);
|
||||
for (j, resp) in out.responses {
|
||||
|
||||
@@ -532,6 +532,7 @@
|
||||
Condition identifier: Идентификатор на условие:
|
||||
Conflict {n} of {count} Конфликт {n} от {count}
|
||||
Connect Selected End Points Свързване на избраните крайни точки
|
||||
Connect Свързване
|
||||
Consolidate All Windows Обединяване на всички прозорци
|
||||
Constrain Angle Заключване на ъгъла
|
||||
Constrain Width and Height Proportions Запазване на пропорциите на ширина и височина
|
||||
@@ -773,6 +774,7 @@
|
||||
Document open password Парола за отваряне на документ
|
||||
Document profile Профил на документ
|
||||
Document Raster Effects Settings Настройки на ефектите за растеризиране в документа
|
||||
Use Document Raster Effects Resolution Използване на разделителната способност от настройките на ефектите за растеризиране в документа
|
||||
Document Raster Effects Settings Dialog Диалогов прозорец за настройки на ефектите за растеризиране в документа
|
||||
Document Raster Effects Settings… Настройки на ефектите за растеризиране в документа…
|
||||
Document Setup Настройка на документ
|
||||
@@ -1133,6 +1135,7 @@
|
||||
Font List Списък с шрифтове
|
||||
Font Preview Size Размер за преглед на шрифта
|
||||
Font Size Размер на шрифта
|
||||
Variable Font Променлив шрифт
|
||||
Font: Шрифт:
|
||||
Fonts Шрифтове
|
||||
Fonts in Document Шрифтове в документа
|
||||
@@ -5125,3 +5128,5 @@ theme Appearance Външен вид
|
||||
@msg name is empty името е празно
|
||||
@msg no symbol named `{n}` няма символ с име `{n}`
|
||||
Enable Guides for 9-Slice Scaling Включване на водачи за 9-slice мащабиране
|
||||
Persistent Постоянна
|
||||
Library Panel Options Опции на панела с библиотека
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -395,6 +395,7 @@
|
||||
Font Family & Style Rodina a řez písma
|
||||
Font Family, Style & Size Rodina, řez a velikost písma
|
||||
Font Size Velikost písma
|
||||
Variable Font Variabilní písmo
|
||||
Text Fill Color Barva výplně textu
|
||||
Text Stroke Color Barva tahu textu
|
||||
All on Same Layers Vše ve stejných vrstvách
|
||||
@@ -1071,6 +1072,7 @@ lighting Light Světlo
|
||||
Click or drag Kliknutí nebo tažení
|
||||
Click the first point Kliknutí na první bod
|
||||
Connect Selected End Points Spojit vybrané koncové body
|
||||
Connect Spojit
|
||||
Convert Selected Anchor Points to Corner Převést vybrané kotevní body na rohové
|
||||
Convert Selected Anchor Points to Smooth Převést vybrané kotevní body na hladké
|
||||
Convert: Převést:
|
||||
@@ -3467,6 +3469,7 @@ theme Appearance Motiv
|
||||
Save Print Preset Uložit přednastavení tisku
|
||||
Print Tiling Pages Stránky dlaždic tisku
|
||||
Document Raster Effects Settings Nastavení rastrových efektů dokumentu
|
||||
Use Document Raster Effects Resolution Použít rozlišení z nastavení rastrových efektů dokumentu
|
||||
Artwork Colors Barvy kresby
|
||||
Randomize Colors Náhodné barvy
|
||||
Recolor Artwork Přebarvit kresbu
|
||||
@@ -5149,3 +5152,11 @@ mark-style Japanese Japonské
|
||||
@msg a linked image can't become a gradient mesh: embed it first propojený obraz nelze převést na síť přechodu: nejprve jej vložte
|
||||
@msg the image has no usable size or position obraz nemá použitelnou velikost ani polohu
|
||||
@msg the image's pixels can't be read pixely obrazu nelze přečíst
|
||||
Persistent Trvalé
|
||||
Library Panel Options Možnosti panelu knihovny
|
||||
@msg Persistent libraries not found: {files} Trvalé knihovny nebyly nalezeny: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` není panel knihovny (viz window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open panel knihovny `{given}` není otevřený
|
||||
@msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz byl vektorizován v rozlišení {_1} × {_2} pixelů, zmenšený z {_3} × {_4} (nejvýše {_5} megapixelů).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -361,6 +361,7 @@
|
||||
Font Family & Style Skrifttypefamilie og -stil
|
||||
Font Family, Style & Size Skrifttypefamilie, stil og størrelse
|
||||
Font Size Skriftstørrelse
|
||||
Variable Font Variabel skrifttype
|
||||
Text Fill Color Tekstfyldfarve
|
||||
Text Stroke Color Tekststregfarve
|
||||
All on Same Layers Alt på samme lag
|
||||
@@ -996,6 +997,7 @@ lighting Light Lys
|
||||
Click or drag Klik eller træk
|
||||
Click the first point Klik på det første punkt
|
||||
Connect Selected End Points Forbind markerede slutpunkter
|
||||
Connect Forbind
|
||||
Convert Selected Anchor Points to Corner Konvertér markerede ankerpunkter til hjørnepunkter
|
||||
Convert Selected Anchor Points to Smooth Konvertér markerede ankerpunkter til glatte punkter
|
||||
Convert: Konvertér:
|
||||
@@ -3237,6 +3239,7 @@ theme Appearance Udseende
|
||||
Save Print Preset Gem udskriftsforudindstilling
|
||||
Print Tiling Pages Udskriv sideopdelte sider
|
||||
Document Raster Effects Settings Indstillinger for dokumentets rasteriseringseffekter
|
||||
Use Document Raster Effects Resolution Brug opløsningen fra indstillingerne for dokumentets rasteriseringseffekter
|
||||
Artwork Colors Farver i grafik
|
||||
Randomize Colors Tilfældiggør farver
|
||||
Recolor Artwork Omfarv grafik
|
||||
@@ -5149,3 +5152,5 @@ swatch Bright Violet Kraftig violet
|
||||
Text Orientation Tekstretning
|
||||
Thread Text Sammenkæd tekst
|
||||
Typing Indtastning
|
||||
Persistent Vedvarende
|
||||
Library Panel Options Indstillinger for bibliotekspanel
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -532,6 +532,7 @@
|
||||
Condition identifier: Kennung der Ausgabebedingung:
|
||||
Conflict {n} of {count} Konflikt {n} von {count}
|
||||
Connect Selected End Points Ausgewählte Endpunkte verbinden
|
||||
Connect Verbinden
|
||||
Consolidate All Windows Alle Fenster zusammenführen
|
||||
Constrain Angle Winkelbeschränkung
|
||||
Constrain Width and Height Proportions Proportionen von Breite und Höhe beibehalten
|
||||
@@ -774,6 +775,7 @@
|
||||
Document open password Kennwort zum Öffnen des Dokuments
|
||||
Document profile Dokumentprofil
|
||||
Document Raster Effects Settings Dokument-Rastereffekt-Einstellungen
|
||||
Use Document Raster Effects Resolution Auflösung der Dokument-Rastereffekt-Einstellungen verwenden
|
||||
Document Raster Effects Settings Dialog Dialogfeld „Dokument-Rastereffekt-Einstellungen“
|
||||
Document Raster Effects Settings… Dokument-Rastereffekt-Einstellungen…
|
||||
Document Setup Dokument einrichten
|
||||
@@ -1151,6 +1153,7 @@ file-filter Templates Vorlagen
|
||||
Font List Schriftliste
|
||||
Font Preview Size Größe der Schriftvorschau
|
||||
Font Size Schriftgrad
|
||||
Variable Font Variable Schrift
|
||||
Font: Schrift:
|
||||
Fonts Schriften
|
||||
Fonts in Document Schriften im Dokument
|
||||
@@ -4724,3 +4727,11 @@ effect Regular Regelmäßig
|
||||
@msg a linked image can't become a gradient mesh: embed it first ein verknüpftes Bild kann kein Verlaufsgitter werden: Betten Sie es zuerst ein
|
||||
@msg the image has no usable size or position das Bild hat keine verwendbare Größe oder Position
|
||||
@msg the image's pixels can't be read die Pixel des Bildes können nicht gelesen werden
|
||||
Persistent Dauerhaft
|
||||
Library Panel Options Optionen für das Bibliotheksbedienfeld
|
||||
@msg Persistent libraries not found: {files} Dauerhafte Bibliotheken nicht gefunden: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ist kein Bibliotheksbedienfeld (siehe window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open Bibliotheksbedienfeld `{given}` ist nicht geöffnet
|
||||
@msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Das Bild wurde mit {_1} × {_2} Pixeln nachgezeichnet, verkleinert von {_3} × {_4} (höchstens {_5} Megapixel).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -506,6 +506,7 @@
|
||||
Condition identifier: Αναγνωριστικό συνθήκης:
|
||||
Conflict {n} of {count} Διένεξη {n} από {count}
|
||||
Connect Selected End Points Σύνδεση επιλεγμένων τελικών σημείων
|
||||
Connect Σύνδεση
|
||||
Consolidate All Windows Συγχώνευση όλων των παραθύρων
|
||||
Constrain Angle Περιορισμός γωνίας
|
||||
Constrain Width and Height Proportions Διατήρηση αναλογιών πλάτους και ύψους
|
||||
@@ -746,6 +747,7 @@
|
||||
Document open password Κωδικός ανοίγματος εγγράφου
|
||||
Document profile Προφίλ εγγράφου
|
||||
Document Raster Effects Settings Ρυθμίσεις εφέ ράστερ εγγράφου
|
||||
Use Document Raster Effects Resolution Χρήση ανάλυσης από τις ρυθμίσεις εφέ ράστερ εγγράφου
|
||||
Document Raster Effects Settings Dialog Παράθυρο ρυθμίσεων εφέ ράστερ εγγράφου
|
||||
Document Raster Effects Settings… Ρυθμίσεις εφέ ράστερ εγγράφου…
|
||||
Document Setup Ρύθμιση εγγράφου
|
||||
@@ -1099,6 +1101,7 @@
|
||||
Font List Λίστα γραμματοσειρών
|
||||
Font Preview Size Μέγεθος προεπισκόπησης γραμματοσειράς
|
||||
Font Size Μέγεθος γραμματοσειράς
|
||||
Variable Font Μεταβλητή γραμματοσειρά
|
||||
Font: Γραμματοσειρά:
|
||||
Fonts Γραμματοσειρές
|
||||
Fonts in Document Γραμματοσειρές στο έγγραφο
|
||||
@@ -5101,3 +5104,11 @@ effect Points Σημεία
|
||||
@msg a linked image can't become a gradient mesh: embed it first μια συνδεδεμένη εικόνα δεν μπορεί να γίνει πλέγμα διαβάθμισης: ενσωματώστε την πρώτα
|
||||
@msg the image has no usable size or position η εικόνα δεν έχει χρησιμοποιήσιμο μέγεθος ή θέση
|
||||
@msg the image's pixels can't be read τα εικονοστοιχεία της εικόνας δεν μπορούν να διαβαστούν
|
||||
Persistent Μόνιμη
|
||||
Library Panel Options Επιλογές πίνακα βιβλιοθήκης
|
||||
@msg Persistent libraries not found: {files} Δεν βρέθηκαν μόνιμες βιβλιοθήκες: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) Το `{given}` δεν είναι πίνακας βιβλιοθήκης (δείτε window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open ο πίνακας βιβλιοθήκης `{given}` δεν είναι ανοιχτός
|
||||
@msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Η εικόνα ανιχνεύτηκε σε {_1} × {_2} εικονοστοιχεία, σμικρυμένη από {_3} × {_4} (το πολύ {_5} megapixel).
|
||||
|
Can't render this file because it contains an unexpected character in line 1996 and column 19.
|
@@ -547,6 +547,7 @@
|
||||
Condition identifier: Identificador de condición:
|
||||
Conflict {n} of {count} Conflicto {n} de {count}
|
||||
Connect Selected End Points Conectar los puntos finales seleccionados
|
||||
Connect Conectar
|
||||
Consolidate All Windows Consolidar todas las ventanas
|
||||
Constrain Angle Restringir ángulo
|
||||
Constrain Width and Height Proportions Restringir proporciones de anchura y altura
|
||||
@@ -788,6 +789,7 @@
|
||||
Document open password Contraseña de apertura del documento
|
||||
Document profile Perfil del documento
|
||||
Document Raster Effects Settings Ajustes de efectos de rasterización del documento
|
||||
Use Document Raster Effects Resolution Usar la resolución de los ajustes de efectos de rasterización del documento
|
||||
Document Raster Effects Settings Dialog Cuadro de diálogo Ajustes de efectos de rasterización del documento
|
||||
Document Raster Effects Settings… Ajustes de efectos de rasterización del documento…
|
||||
Document Setup Ajustar documento
|
||||
@@ -1158,6 +1160,7 @@
|
||||
Font List Lista de fuentes
|
||||
Font Preview Size Tamaño de vista previa de fuente
|
||||
Font Size Tamaño de fuente
|
||||
Variable Font Fuente variable
|
||||
Font: Fuente:
|
||||
Fonts Fuentes
|
||||
Fonts in Document Fuentes del documento
|
||||
@@ -5091,3 +5094,11 @@ mark-style Japanese Japonesas
|
||||
@msg a linked image can't become a gradient mesh: embed it first una imagen enlazada no puede convertirse en una malla de degradado: incrústela primero
|
||||
@msg the image has no usable size or position la imagen no tiene un tamaño ni una posición utilizables
|
||||
@msg the image's pixels can't be read no se pueden leer los píxeles de la imagen
|
||||
Persistent Persistente
|
||||
Library Panel Options Opciones del panel de biblioteca
|
||||
@msg Persistent libraries not found: {files} No se encontraron bibliotecas persistentes: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` no es un panel de biblioteca (consulte window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open el panel de biblioteca `{given}` no está abierto
|
||||
@msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imagen se calcó a {_1} × {_2} píxeles, reducida desde {_3} × {_4} ({_5} megapíxeles como máximo).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -392,6 +392,7 @@
|
||||
Font Family & Style Fonttiperhe ja tyyli
|
||||
Font Family, Style & Size Fonttiperhe, tyyli ja koko
|
||||
Font Size Fonttikoko
|
||||
Variable Font Muuttuva fontti
|
||||
Text Fill Color Tekstin täyttöväri
|
||||
Text Stroke Color Tekstin viivan väri
|
||||
All on Same Layers Kaikki samoilla tasoilla
|
||||
@@ -1049,6 +1050,7 @@ lighting Light Valo
|
||||
Click or drag Napsauta tai vedä
|
||||
Click the first point Napsauta ensimmäistä pistettä
|
||||
Connect Selected End Points Yhdistä valitut päätepisteet
|
||||
Connect Yhdistä
|
||||
Convert Selected Anchor Points to Corner Muunna valitut ankkuripisteet kulmapisteiksi
|
||||
Convert Selected Anchor Points to Smooth Muunna valitut ankkuripisteet pehmeiksi
|
||||
Convert: Muunna:
|
||||
@@ -3402,6 +3404,7 @@ theme Appearance Ulkoasu
|
||||
Save Print Preset Tallenna tulostuksen esiasetus
|
||||
Print Tiling Pages Tulosta sivujen ruudutus
|
||||
Document Raster Effects Settings Asiakirjan rasteritehosteiden asetukset
|
||||
Use Document Raster Effects Resolution Käytä asiakirjan rasteritehosteiden asetusten tarkkuutta
|
||||
Artwork Colors Kuvituksen värit
|
||||
Randomize Colors Satunnaista värit
|
||||
Recolor Artwork Väritä kuvitus uudelleen
|
||||
@@ -5113,3 +5116,11 @@ mark-style Japanese Japanilainen
|
||||
@msg a linked image can't become a gradient mesh: embed it first linkitetystä kuvasta ei voi tehdä liukuväriverkkoa: upota se ensin
|
||||
@msg the image has no usable size or position kuvalla ei ole käyttökelpoista kokoa tai sijaintia
|
||||
@msg the image's pixels can't be read kuvan pikseleitä ei voi lukea
|
||||
Persistent Pysyvä
|
||||
Library Panel Options Kirjastopaneelin asetukset
|
||||
@msg Persistent libraries not found: {files} Pysyviä kirjastoja ei löytynyt: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ei ole kirjastopaneeli (katso window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open kirjastopaneeli `{given}` ei ole auki
|
||||
@msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Kuva jäljitettiin kokoon {_1} × {_2} kuvapistettä pienennettynä koosta {_3} × {_4} (enintään {_5} megapikseliä).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -548,6 +548,7 @@
|
||||
Condition identifier: Identifiant de condition :
|
||||
Conflict {n} of {count} Conflit {n} sur {count}
|
||||
Connect Selected End Points Relier les extrémités sélectionnées
|
||||
Connect Relier
|
||||
Consolidate All Windows Regrouper toutes les fenêtres
|
||||
Constrain Angle Contraindre l’angle
|
||||
Constrain Width and Height Proportions Conserver les proportions de largeur et de hauteur
|
||||
@@ -790,6 +791,7 @@
|
||||
Document open password Mot de passe d’ouverture du document
|
||||
Document profile Profil du document
|
||||
Document Raster Effects Settings Paramètres des effets de pixellisation du document
|
||||
Use Document Raster Effects Resolution Utiliser la résolution des paramètres des effets de pixellisation du document
|
||||
Document Raster Effects Settings Dialog Boîte de dialogue Paramètres des effets de pixellisation du document
|
||||
Document Raster Effects Settings… Paramètres des effets de pixellisation du document…
|
||||
Document Setup Format de document
|
||||
@@ -1157,6 +1159,7 @@
|
||||
Font List Liste des polices
|
||||
Font Preview Size Taille de l’aperçu des polices
|
||||
Font Size Taille de police
|
||||
Variable Font Police variable
|
||||
Font: Police :
|
||||
Fonts Polices
|
||||
Fonts in Document Polices du document
|
||||
@@ -5148,3 +5151,11 @@ theme Appearance Apparence
|
||||
@msg a linked image can't become a gradient mesh: embed it first une image liée ne peut pas devenir un filet de dégradé : incorporez-la d’abord
|
||||
@msg the image has no usable size or position l’image n’a ni taille ni position utilisables
|
||||
@msg the image's pixels can't be read impossible de lire les pixels de l’image
|
||||
Persistent Persistante
|
||||
Library Panel Options Options du panneau de bibliothèque
|
||||
@msg Persistent libraries not found: {files} Bibliothèques persistantes introuvables : {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` n’est pas un panneau de bibliothèque (voir window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open le panneau de bibliothèque `{given}` n’est pas ouvert
|
||||
@msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’image a été vectorisée à {_1} × {_2} pixels, réduite de {_3} × {_4} ({_5} mégapixels au plus).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -732,6 +732,7 @@
|
||||
Condition identifier: Feltételazonosító:
|
||||
Conflict {n} of {count} Ütközés: {n}/{count}
|
||||
Connect Selected End Points Kijelölt végpontok összekötése
|
||||
Connect Összekötés
|
||||
Consolidate All Windows Összes ablak egyesítése
|
||||
Constrain Korlátozás
|
||||
Constrain Angle Korlátozási szög
|
||||
@@ -1029,6 +1030,7 @@
|
||||
Document open password Dokumentummegnyitási jelszó
|
||||
Document profile Dokumentumprofil
|
||||
Document Raster Effects Settings Dokumentum raszteres effektusainak beállítása
|
||||
Use Document Raster Effects Resolution A dokumentum raszteres effektusainak felbontása
|
||||
Document Raster Effects Settings Dialog Dokumentum raszteres effektusainak beállítása – párbeszédablak
|
||||
Document Raster Effects Settings… Dokumentum raszteres effektusainak beállítása…
|
||||
Document Setup Dokumentumbeállítás
|
||||
@@ -1454,6 +1456,7 @@ file-filter Templates Sablonok
|
||||
Font List Betűtípuslista
|
||||
Font Preview Size Betűtípus-előnézet mérete
|
||||
Font Size Betűméret
|
||||
Variable Font Változó betűtípus
|
||||
Font: Betűtípus:
|
||||
Fonts Betűtípusok
|
||||
Fonts in Document A dokumentum betűtípusai
|
||||
@@ -5135,3 +5138,11 @@ imageTrace Outline Körvonal
|
||||
@msg a linked image can't become a gradient mesh: embed it first csatolt kép nem alakítható színátmenetes hálóvá: előbb ágyazza be
|
||||
@msg the image has no usable size or position a képnek nincs használható mérete vagy helyzete
|
||||
@msg the image's pixels can't be read a kép képpontjai nem olvashatók
|
||||
Persistent Állandó
|
||||
Library Panel Options Könyvtárpanel beállításai
|
||||
@msg Persistent libraries not found: {files} Nem találhatók állandó könyvtárak: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) A(z) `{given}` nem könyvtárpanel (lásd: window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open a(z) `{given}` könyvtárpanel nincs megnyitva
|
||||
@msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A kép vektorizálása {_1} × {_2} képponton történt, {_3} × {_4} méretről kicsinyítve (legfeljebb {_5} megapixel).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -361,6 +361,7 @@
|
||||
Font Family & Style Keluarga & gaya font
|
||||
Font Family, Style & Size Keluarga, gaya & ukuran font
|
||||
Font Size Ukuran font
|
||||
Variable Font Font variabel
|
||||
Text Fill Color Warna isian teks
|
||||
Text Stroke Color Warna stroke teks
|
||||
All on Same Layers Semua di lapisan yang sama
|
||||
@@ -996,6 +997,7 @@ lighting Light Cahaya
|
||||
Click or drag Klik atau seret
|
||||
Click the first point Klik titik pertama
|
||||
Connect Selected End Points Hubungkan titik akhir terpilih
|
||||
Connect Hubungkan
|
||||
Convert Selected Anchor Points to Corner Konversi titik jangkar terpilih menjadi sudut
|
||||
Convert Selected Anchor Points to Smooth Konversi titik jangkar terpilih menjadi halus
|
||||
Convert: Konversi:
|
||||
@@ -3237,6 +3239,7 @@ theme Appearance Tampilan
|
||||
Save Print Preset Simpan preset cetak
|
||||
Print Tiling Pages Halaman pengubinan cetak
|
||||
Document Raster Effects Settings Pengaturan efek raster dokumen
|
||||
Use Document Raster Effects Resolution Gunakan resolusi dari pengaturan efek raster dokumen
|
||||
Artwork Colors Warna karya seni
|
||||
Randomize Colors Acak warna
|
||||
Recolor Artwork Warnai ulang karya seni
|
||||
@@ -5149,3 +5152,5 @@ swatch Bright Violet Violet cerah
|
||||
Text Orientation Orientasi teks
|
||||
Thread Text Untai teks
|
||||
Typing Mengetik
|
||||
Persistent Persisten
|
||||
Library Panel Options Opsi panel pustaka
|
||||
|
Can't render this file because it contains an unexpected character in line 3758 and column 19.
|
@@ -547,6 +547,7 @@
|
||||
Condition identifier: Identificativo condizione:
|
||||
Conflict {n} of {count} Conflitto {n} di {count}
|
||||
Connect Selected End Points Collega i punti finali selezionati
|
||||
Connect Collega
|
||||
Consolidate All Windows Consolida tutte le finestre
|
||||
Constrain Angle Vincola angolo
|
||||
Constrain Width and Height Proportions Vincola proporzioni di larghezza e altezza
|
||||
@@ -788,6 +789,7 @@
|
||||
Document open password Password di apertura del documento
|
||||
Document profile Profilo documento
|
||||
Document Raster Effects Settings Impostazioni effetti raster documento
|
||||
Use Document Raster Effects Resolution Usa la risoluzione delle impostazioni effetti raster documento
|
||||
Document Raster Effects Settings Dialog Finestra di dialogo Impostazioni effetti raster documento
|
||||
Document Raster Effects Settings… Impostazioni effetti raster documento…
|
||||
Document Setup Imposta documento
|
||||
@@ -1167,6 +1169,7 @@ file-filter Templates Modelli
|
||||
Font List Elenco font
|
||||
Font Preview Size Dimensione anteprima font
|
||||
Font Size Dimensione font
|
||||
Variable Font Font variabile
|
||||
Font: Font:
|
||||
Fonts Font
|
||||
Fonts in Document Font nel documento
|
||||
@@ -4762,3 +4765,11 @@ lighting Light Luce
|
||||
@msg a linked image can't become a gradient mesh: embed it first un’immagine collegata non può diventare una trama sfumata: incorporala prima
|
||||
@msg the image has no usable size or position l’immagine non ha dimensioni o posizione utilizzabili
|
||||
@msg the image's pixels can't be read impossibile leggere i pixel dell’immagine
|
||||
Persistent Persistente
|
||||
Library Panel Options Opzioni pannello libreria
|
||||
@msg Persistent libraries not found: {files} Librerie persistenti non trovate: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` non è un pannello libreria (vedi window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open il pannello libreria `{given}` non è aperto
|
||||
@msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’immagine è stata vettorializzata a {_1} × {_2} pixel, ridotta da {_3} × {_4} (al massimo {_5} megapixel).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -26,6 +26,7 @@
|
||||
Center 中央
|
||||
Character Alignment 文字揃え
|
||||
Connect Selected End Points 選択した端点を連結
|
||||
Connect 連結
|
||||
Convert Selected Anchor Points to Corner 選択したアンカーポイントをコーナーポイントに変換
|
||||
Convert Selected Anchor Points to Smooth 選択したアンカーポイントをスムーズポイントに変換
|
||||
Convert: 変換:
|
||||
@@ -390,6 +391,7 @@
|
||||
Font Family & Style フォントファミリー (スタイル)
|
||||
Font Family, Style & Size フォントファミリー (スタイルとサイズ)
|
||||
Font Size フォントサイズ
|
||||
Variable Font バリアブルフォント
|
||||
Text Fill Color テキストカラー (塗り)
|
||||
Text Stroke Color テキストカラー (線)
|
||||
All on Same Layers 同じレイヤー上のすべて
|
||||
@@ -1269,6 +1271,7 @@
|
||||
Document Ink Options ドキュメントのインキオプション
|
||||
Document JSON ドキュメント JSON
|
||||
Document Raster Effects Settings ドキュメントのラスタライズ効果設定
|
||||
Use Document Raster Effects Resolution ドキュメントのラスタライズ効果設定の解像度を使用
|
||||
Document Raster Effects Settings Dialog ドキュメントのラスタライズ効果設定ダイアログ
|
||||
Document Setup ドキュメント設定
|
||||
Document Title: ドキュメントのタイトル:
|
||||
@@ -4803,3 +4806,11 @@ lighting Light ライト
|
||||
@msg a linked image can't become a gradient mesh: embed it first リンクされた画像はグラデーションメッシュにできません: 先に埋め込んでください
|
||||
@msg the image has no usable size or position 画像に使用できるサイズまたは位置がありません
|
||||
@msg the image's pixels can't be read 画像のピクセルを読み取れません
|
||||
Persistent 永続
|
||||
Library Panel Options ライブラリパネルオプション
|
||||
@msg Persistent libraries not found: {files} 永続ライブラリが見つかりません: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` はライブラリパネルではありません (window.swatchLibrary を参照)
|
||||
@msg library panel `{given}` isn't open ライブラリパネル `{given}` は開いていません
|
||||
@msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 画像は {_1} × {_2} ピクセルで、一辺が {LARGE_TRACE_SIDE} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 画像は {_3} × {_4} から縮小した {_1} × {_2} ピクセルでトレースされました (最大 {_5} メガピクセル)。
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -22,6 +22,7 @@
|
||||
Center 가운데
|
||||
Character Alignment 문자 정렬
|
||||
Connect Selected End Points 선택한 끝점 연결
|
||||
Connect 연결
|
||||
Convert Selected Anchor Points to Corner 선택한 앵커 포인트를 모퉁이로 변환
|
||||
Convert Selected Anchor Points to Smooth 선택한 앵커 포인트를 부드러운 곡선으로 변환
|
||||
Convert: 변환:
|
||||
@@ -386,6 +387,7 @@
|
||||
Font Family & Style 글꼴 패밀리 및 스타일
|
||||
Font Family, Style & Size 글꼴 패밀리, 스타일 및 크기
|
||||
Font Size 글꼴 크기
|
||||
Variable Font 가변 글꼴
|
||||
Text Fill Color 문자 칠 색상
|
||||
Text Stroke Color 문자 선 색상
|
||||
All on Same Layers 같은 레이어의 모두
|
||||
@@ -1264,6 +1266,7 @@
|
||||
Document Ink Options 문서 잉크 옵션
|
||||
Document JSON 문서 JSON
|
||||
Document Raster Effects Settings 문서 래스터 효과 설정
|
||||
Use Document Raster Effects Resolution 문서 래스터 효과 설정의 해상도 사용
|
||||
Document Raster Effects Settings Dialog 문서 래스터 효과 설정 대화상자
|
||||
Document Setup 문서 설정
|
||||
Document Title: 문서 제목:
|
||||
@@ -4726,3 +4729,11 @@ lighting Light 광원
|
||||
@msg a linked image can't become a gradient mesh: embed it first 연결된 이미지는 그라디언트 메시로 만들 수 없습니다: 먼저 포함하십시오
|
||||
@msg the image has no usable size or position 이미지에 사용할 수 있는 크기나 위치가 없습니다
|
||||
@msg the image's pixels can't be read 이미지의 픽셀을 읽을 수 없습니다
|
||||
Persistent 지속
|
||||
Library Panel Options 라이브러리 패널 옵션
|
||||
@msg Persistent libraries not found: {files} 지속 라이브러리를 찾을 수 없습니다: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}`은(는) 라이브러리 패널이 아닙니다(window.swatchLibrary 참조)
|
||||
@msg library panel `{given}` isn't open 라이브러리 패널 `{given}`이(가) 열려 있지 않습니다
|
||||
@msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 이미지가 {_1} × {_2}픽셀로 한 변이 {LARGE_TRACE_SIDE}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 이미지를 {_3} × {_4}에서 축소한 {_1} × {_2}픽셀로 추적했습니다(최대 {_5}메가픽셀).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -361,6 +361,7 @@
|
||||
Font Family & Style Lettertypefamilie en -stijl
|
||||
Font Family, Style & Size Lettertypefamilie, -stijl en -grootte
|
||||
Font Size Lettertypegrootte
|
||||
Variable Font Variabel lettertype
|
||||
Text Fill Color Tekstvulkleur
|
||||
Text Stroke Color Tekstcontourkleur
|
||||
All on Same Layers Alles op dezelfde lagen
|
||||
@@ -996,6 +997,7 @@ lighting Light Licht
|
||||
Click or drag Klik of sleep
|
||||
Click the first point Klik op het eerste punt
|
||||
Connect Selected End Points Geselecteerde eindpunten verbinden
|
||||
Connect Verbinden
|
||||
Convert Selected Anchor Points to Corner Geselecteerde ankerpunten omzetten in hoekpunten
|
||||
Convert Selected Anchor Points to Smooth Geselecteerde ankerpunten omzetten in vloeiende punten
|
||||
Convert: Omzetten:
|
||||
@@ -3237,6 +3239,7 @@ theme Appearance Vormgeving
|
||||
Save Print Preset Afdrukvoorinstelling opslaan
|
||||
Print Tiling Pages Pagina's tegelgewijs afdrukken
|
||||
Document Raster Effects Settings Instellingen voor rastereffecten in document
|
||||
Use Document Raster Effects Resolution Resolutie van de instellingen voor rastereffecten in document gebruiken
|
||||
Artwork Colors Kleuren in het artwork
|
||||
Randomize Colors Kleuren willekeurig maken
|
||||
Recolor Artwork Artwork opnieuw kleuren
|
||||
@@ -5149,3 +5152,5 @@ swatch Bright Violet Felviolet
|
||||
Text Orientation Tekstrichting
|
||||
Thread Text Tekst koppelen
|
||||
Typing Typen
|
||||
Persistent Permanent
|
||||
Library Panel Options Opties voor bibliotheekpaneel
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -355,6 +355,7 @@
|
||||
Font Family & Style Rodzina i styl czcionki
|
||||
Font Family, Style & Size Rodzina, styl i rozmiar czcionki
|
||||
Font Size Rozmiar czcionki
|
||||
Variable Font Czcionka zmienna
|
||||
Text Fill Color Kolor wypełnienia tekstu
|
||||
Text Stroke Color Kolor obrysu tekstu
|
||||
All on Same Layers Wszystko na tych samych warstwach
|
||||
@@ -1004,6 +1005,7 @@ lighting Light Światło
|
||||
Click or drag Kliknij lub przeciągnij
|
||||
Click the first point Kliknij pierwszy punkt
|
||||
Connect Selected End Points Połącz zaznaczone punkty końcowe
|
||||
Connect Połącz
|
||||
Convert Selected Anchor Points to Corner Konwertuj zaznaczone punkty kontrolne na narożne
|
||||
Convert Selected Anchor Points to Smooth Konwertuj zaznaczone punkty kontrolne na gładkie
|
||||
Convert: Konwertuj:
|
||||
@@ -3329,6 +3331,7 @@ theme Appearance Wygląd
|
||||
Save Print Preset Zapisz ustawienie drukowania
|
||||
Print Tiling Pages Strony kafelkowania wydruku
|
||||
Document Raster Effects Settings Ustawienia efektów rastrowych dokumentu
|
||||
Use Document Raster Effects Resolution Użyj rozdzielczości z ustawień efektów rastrowych dokumentu
|
||||
Artwork Colors Kolory kompozycji
|
||||
Randomize Colors Losuj kolory
|
||||
Recolor Artwork Zmień kolory kompozycji
|
||||
@@ -5001,3 +5004,11 @@ mark-style Japanese Japońskie
|
||||
@msg a linked image can't become a gradient mesh: embed it first połączony obraz nie może stać się siatką gradientu: najpierw go osadź
|
||||
@msg the image has no usable size or position obraz nie ma użytecznego rozmiaru ani położenia
|
||||
@msg the image's pixels can't be read nie można odczytać pikseli obrazu
|
||||
Persistent Trwały
|
||||
Library Panel Options Opcje panelu biblioteki
|
||||
@msg Persistent libraries not found: {files} Nie znaleziono trwałych bibliotek: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` nie jest panelem biblioteki (zobacz window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open panel biblioteki `{given}` nie jest otwarty
|
||||
@msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz obrysowano w rozdzielczości {_1} × {_2} pikseli, po zmniejszeniu z {_3} × {_4} (najwyżej {_5} megapikseli).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -705,6 +705,7 @@
|
||||
Document Ink Options Opções de Tinta do Documento
|
||||
Document JSON JSON do Documento
|
||||
Document Raster Effects Settings Configurações de Efeitos de Raster do Documento
|
||||
Use Document Raster Effects Resolution Usar a Resolução das Configurações de Efeitos de Raster do Documento
|
||||
Document Raster Effects Settings Dialog Diálogo de Configurações de Efeitos de Raster do Documento
|
||||
Document Raster Effects Settings… Configurações de Efeitos de Raster do Documento…
|
||||
Document Setup Configuração do Documento
|
||||
@@ -1017,6 +1018,7 @@
|
||||
Font List Lista de Fontes
|
||||
Font Preview Size Tamanho da Visualização da Fonte
|
||||
Font Size Tamanho da Fonte
|
||||
Variable Font Fonte Variável
|
||||
Font: Fonte:
|
||||
Fonts Fontes
|
||||
Fonts in Document Fontes no Documento
|
||||
@@ -3057,6 +3059,7 @@ lighting Light Luz
|
||||
Above Text Acima do texto
|
||||
Position Panel Above Text Posicionar painel acima do texto
|
||||
Connect Selected End Points Conectar extremidades selecionadas
|
||||
Connect Conectar
|
||||
Convert Selected Anchor Points to Corner Converter pontos de ancoragem selecionados em canto
|
||||
Convert Selected Anchor Points to Smooth Converter pontos de ancoragem selecionados em suaves
|
||||
Convert: Converter:
|
||||
@@ -5066,3 +5069,11 @@ mark-style Japanese Japonesas
|
||||
@msg a linked image can't become a gradient mesh: embed it first uma imagem vinculada não pode virar uma malha de degradê: incorpore-a primeiro
|
||||
@msg the image has no usable size or position a imagem não tem tamanho nem posição utilizáveis
|
||||
@msg the image's pixels can't be read não é possível ler os pixels da imagem
|
||||
Persistent Persistente
|
||||
Library Panel Options Opções do Painel de Biblioteca
|
||||
@msg Persistent libraries not found: {files} Bibliotecas persistentes não encontradas: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` não é um painel de biblioteca (consulte window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open o painel de biblioteca `{given}` não está aberto
|
||||
@msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A imagem foi traçada em {_1} × {_2} pixels, reduzida de {_3} × {_4} (no máximo {_5} megapixels).
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -545,6 +545,7 @@
|
||||
Condition identifier: Идентификатор условия:
|
||||
Conflict {n} of {count} Конфликт {n} из {count}
|
||||
Connect Selected End Points Соединить выбранные конечные точки
|
||||
Connect Соединить
|
||||
Consolidate All Windows Объединить все окна
|
||||
Constrain Angle Ограничить угол
|
||||
Constrain Width and Height Proportions Сохранять пропорции ширины и высоты
|
||||
@@ -786,6 +787,7 @@
|
||||
Document open password Пароль для открытия документа
|
||||
Document profile Профиль документа
|
||||
Document Raster Effects Settings Параметры растровых эффектов документа
|
||||
Use Document Raster Effects Resolution Использовать разрешение из параметров растровых эффектов документа
|
||||
Document Raster Effects Settings Dialog Диалоговое окно «Параметры растровых эффектов документа»
|
||||
Document Raster Effects Settings… Параметры растровых эффектов документа…
|
||||
Document Setup Параметры документа
|
||||
@@ -1165,6 +1167,7 @@ file-filter Templates Шаблоны
|
||||
Font List Список шрифтов
|
||||
Font Preview Size Размер предварительного просмотра шрифта
|
||||
Font Size Размер шрифта
|
||||
Variable Font Вариативный шрифт
|
||||
Font: Шрифт:
|
||||
Fonts Шрифты
|
||||
Fonts in Document Шрифты в документе
|
||||
@@ -4758,3 +4761,11 @@ lighting Light Свет
|
||||
@msg a linked image can't become a gradient mesh: embed it first связанное изображение нельзя превратить в сетчатый градиент: сначала встройте его
|
||||
@msg the image has no usable size or position у изображения нет пригодного размера или положения
|
||||
@msg the image's pixels can't be read не удаётся прочитать пиксели изображения
|
||||
Persistent Постоянная
|
||||
Library Panel Options Параметры панели библиотеки
|
||||
@msg Persistent libraries not found: {files} Постоянные библиотеки не найдены: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` — не панель библиотеки (см. window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open панель библиотеки `{given}` не открыта
|
||||
@msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Изображение {_1} × {_2} пикселей, больше {LARGE_TRACE_SIDE} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Изображение трассировано в размере {_1} × {_2} пикселей, уменьшенное с {_3} × {_4} (не более {_5} мегапикселей).
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -534,6 +534,7 @@
|
||||
Condition identifier: Villkorsidentifierare:
|
||||
Conflict {n} of {count} Konflikt {n} av {count}
|
||||
Connect Selected End Points Sammanfoga markerade slutpunkter
|
||||
Connect Sammanfoga
|
||||
Consolidate All Windows Samla alla fönster
|
||||
Constrain Angle Begränsa vinkel
|
||||
Constrain Width and Height Proportions Behåll proportioner för bredd och höjd
|
||||
@@ -775,6 +776,7 @@
|
||||
Document open password Lösenord för att öppna dokumentet
|
||||
Document profile Dokumentprofil
|
||||
Document Raster Effects Settings Inställningar för rastereffekter i dokument
|
||||
Use Document Raster Effects Resolution Använd upplösningen från inställningarna för rastereffekter i dokument
|
||||
Document Raster Effects Settings Dialog Dialogruta för inställningar för rastereffekter i dokument
|
||||
Document Raster Effects Settings… Inställningar för rastereffekter i dokument…
|
||||
Document Setup Dokumentinställningar
|
||||
@@ -1135,6 +1137,7 @@
|
||||
Font List Teckensnittslista
|
||||
Font Preview Size Storlek på teckensnittsförhandsgranskning
|
||||
Font Size Teckenstorlek
|
||||
Variable Font Variabelt teckensnitt
|
||||
Font: Typsnitt:
|
||||
Fonts Typsnitt
|
||||
Fonts in Document Typsnitt i dokumentet
|
||||
@@ -5105,3 +5108,11 @@ theme Appearance Utseende
|
||||
@msg a linked image can't become a gradient mesh: embed it first en länkad bild kan inte bli ett övertoningsnät: bädda in den först
|
||||
@msg the image has no usable size or position bilden har ingen användbar storlek eller position
|
||||
@msg the image's pixels can't be read bildens pixlar kan inte läsas
|
||||
Persistent Beständig
|
||||
Library Panel Options Alternativ för bibliotekspanelen
|
||||
@msg Persistent libraries not found: {files} Beständiga bibliotek hittades inte: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` är inte en bibliotekspanel (se window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open bibliotekspanelen `{given}` är inte öppen
|
||||
@msg too many library panels were opened in this session för många bibliotekspaneler har öppnats i den här sessionen
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Bilden är {_1} × {_2} pixlar, mer än {LARGE_TRACE_SIDE} på en sida: den vektoriseras, men långsammare. En förinställning med lägre återgivning eller en mindre kopia av bilden går snabbare.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Bilden vektoriserades vid {_1} × {_2} pixlar, nedskalad från {_3} × {_4} (högst {_5} megapixlar).
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -541,6 +541,7 @@
|
||||
Condition identifier: Ідентифікатор умови:
|
||||
Conflict {n} of {count} Конфлікт {n} з {count}
|
||||
Connect Selected End Points З’єднати обрані кінцеві точки
|
||||
Connect З’єднати
|
||||
Consolidate All Windows Об’єднати всі вікна
|
||||
Constrain Angle Обмежити кут
|
||||
Constrain Width and Height Proportions Зберігати пропорції ширини та висоти
|
||||
@@ -782,6 +783,7 @@
|
||||
Document open password Пароль для відкриття документа
|
||||
Document profile Профіль документа
|
||||
Document Raster Effects Settings Параметри растрових ефектів документа
|
||||
Use Document Raster Effects Resolution Використовувати роздільну здатність із параметрів растрових ефектів документа
|
||||
Document Raster Effects Settings Dialog Діалогове вікно «Параметри растрових ефектів документа»
|
||||
Document Raster Effects Settings… Параметри растрових ефектів документа…
|
||||
Document Setup Параметри документа
|
||||
@@ -1161,6 +1163,7 @@ file-filter Templates Шаблони
|
||||
Font List Список шрифтів
|
||||
Font Preview Size Розмір попереднього перегляду шрифту
|
||||
Font Size Розмір шрифту
|
||||
Variable Font Варіативний шрифт
|
||||
Font: Шрифт:
|
||||
Fonts Шрифти
|
||||
Fonts in Document Шрифти у документі
|
||||
@@ -4742,3 +4745,11 @@ lighting Light Світло
|
||||
@msg a linked image can't become a gradient mesh: embed it first зв’язане зображення не можна перетворити на сітчастий градієнт: спочатку вбудуйте його
|
||||
@msg the image has no usable size or position зображення не має придатного розміру чи положення
|
||||
@msg the image's pixels can't be read не вдається прочитати пікселі зображення
|
||||
Persistent Постійна
|
||||
Library Panel Options Параметри панелі бібліотеки
|
||||
@msg Persistent libraries not found: {files} Постійні бібліотеки не знайдено: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` — не панель бібліотеки (див. window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open панель бібліотеки `{given}` не відкрита
|
||||
@msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Зображення {_1} × {_2} пікселів, понад {LARGE_TRACE_SIDE} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Зображення трасовано в розмірі {_1} × {_2} пікселів, зменшене з {_3} × {_4} (щонайбільше {_5} мегапікселів).
|
||||
|
Can't render this file because it contains an unexpected character in line 724 and column 40.
|
@@ -637,6 +637,7 @@
|
||||
Condition identifier: Mã định danh điều kiện:
|
||||
Conflict {n} of {count} Xung đột {n} trong {count}
|
||||
Connect Selected End Points Nối các điểm cuối đã chọn
|
||||
Connect Nối
|
||||
Consolidate All Windows Hợp nhất tất cả cửa sổ
|
||||
Constrain Giữ tỷ lệ
|
||||
Constrain Angle Ràng buộc góc
|
||||
@@ -920,6 +921,7 @@
|
||||
Document open password Mật khẩu mở tài liệu
|
||||
Document profile Hồ sơ tài liệu
|
||||
Document Raster Effects Settings Cài đặt hiệu ứng raster tài liệu
|
||||
Use Document Raster Effects Resolution Dùng độ phân giải trong cài đặt hiệu ứng raster tài liệu
|
||||
Document Raster Effects Settings Dialog Hộp thoại cài đặt hiệu ứng raster tài liệu
|
||||
Document Raster Effects Settings… Cài đặt hiệu ứng raster tài liệu…
|
||||
Document Setup Thiết lập tài liệu
|
||||
@@ -1345,6 +1347,7 @@ file-filter Templates Mẫu
|
||||
Font List Danh sách phông
|
||||
Font Preview Size Cỡ xem trước phông
|
||||
Font Size Cỡ chữ
|
||||
Variable Font Phông chữ biến thiên
|
||||
Font: Phông:
|
||||
Fonts Phông chữ
|
||||
Fonts in Document Phông trong tài liệu
|
||||
@@ -4742,3 +4745,11 @@ theme Appearance Giao diện
|
||||
@msg a linked image can't become a gradient mesh: embed it first không thể biến ảnh liên kết thành lưới chuyển sắc: hãy nhúng ảnh trước
|
||||
@msg the image has no usable size or position ảnh không có kích thước hoặc vị trí dùng được
|
||||
@msg the image's pixels can't be read không đọc được các điểm ảnh của ảnh
|
||||
Persistent Lưu giữ
|
||||
Library Panel Options Tùy chọn bảng thư viện
|
||||
@msg Persistent libraries not found: {files} Không tìm thấy thư viện lưu giữ: {files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` không phải là bảng thư viện (xem window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open bảng thư viện `{given}` chưa được mở
|
||||
@msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Ảnh đã được dò ở {_1} × {_2} điểm ảnh, thu nhỏ từ {_3} × {_4} (tối đa {_5} megapixel).
|
||||
|
Can't render this file because it contains an unexpected character in line 3939 and column 10.
|
@@ -502,6 +502,7 @@
|
||||
Condition identifier: 条件识别码:
|
||||
Conflict {n} of {count} 冲突 {n}/{count}
|
||||
Connect Selected End Points 连接所选端点
|
||||
Connect 连接
|
||||
Consolidate All Windows 合并所有窗口
|
||||
Constrain Angle 约束角度
|
||||
Constrain Width and Height Proportions 约束宽高比例
|
||||
@@ -739,6 +740,7 @@
|
||||
Document open password 文档打开口令
|
||||
Document profile 文档配置文档
|
||||
Document Raster Effects Settings 文档栅格效果设置
|
||||
Use Document Raster Effects Resolution 使用文档栅格效果设置的分辨率
|
||||
Document Raster Effects Settings Dialog 文档栅格效果设置对话框
|
||||
Document Raster Effects Settings… 文档栅格化效果设置…
|
||||
Document Setup 文档设置
|
||||
@@ -1114,6 +1116,7 @@ file-filter Templates 模板
|
||||
Font List 字体列表
|
||||
Font Preview Size 字体预览大小
|
||||
Font Size 字号
|
||||
Variable Font 可变字体
|
||||
Font: 字体:
|
||||
Fonts 字体
|
||||
Fonts in Document 文档中的字体
|
||||
@@ -4985,3 +4988,11 @@ lighting Light 光源
|
||||
@msg a linked image can't become a gradient mesh: embed it first 链接的图像无法转换为渐变网格:请先嵌入它
|
||||
@msg the image has no usable size or position 图像没有可用的尺寸或位置
|
||||
@msg the image's pixels can't be read 无法读取图像的像素
|
||||
Persistent 持久
|
||||
Library Panel Options 库面板选项
|
||||
@msg Persistent libraries not found: {files} 找不到持久库:{files}
|
||||
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` 不是库面板 (请参阅 window.swatchLibrary)
|
||||
@msg library panel `{given}` isn't open 库面板 `{given}` 未打开
|
||||
@msg too many library panels were opened in this session 本次会话中打开的库面板过多
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 图像为 {_1} × {_2} 像素,某一边超过 {LARGE_TRACE_SIDE}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 图像已按 {_1} × {_2} 像素描摹,由 {_3} × {_4} 缩小而来(最多 {_5} 百万像素)。
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -516,6 +516,7 @@
|
||||
Condition identifier: 條件識別碼:
|
||||
Conflict {n} of {count} 衝突 {n}/{count}
|
||||
Connect Selected End Points 連接所選端點
|
||||
Connect 連接
|
||||
Consolidate All Windows 合併所有視窗
|
||||
Constrain Angle 強制角度
|
||||
Constrain Width and Height Proportions 強制寬度與高度比例
|
||||
@@ -757,6 +758,7 @@
|
||||
Document open password 文件開啟密碼
|
||||
Document profile 文件描述檔
|
||||
Document Raster Effects Settings 文件點陣效果設定
|
||||
Use Document Raster Effects Resolution 使用文件點陣效果設定的解析度
|
||||
Document Raster Effects Settings Dialog 文件點陣效果設定對話框
|
||||
Document Raster Effects Settings… 文件點陣效果設定…
|
||||
Document Setup 文件設定
|
||||
@@ -1138,6 +1140,7 @@ file-filter Templates 範本
|
||||
Font List 字體清單
|
||||
Font Preview Size 字體預覽大小
|
||||
Font Size 字體大小
|
||||
Variable Font 可變字體
|
||||
Font: 字體:
|
||||
Fonts 字體
|
||||
Fonts in Document 文件中的字體
|
||||
@@ -4058,3 +4061,5 @@ lighting Light 光源
|
||||
@msg name is empty 名稱為空
|
||||
@msg no symbol named `{n}` 沒有名為 `{n}` 的符號
|
||||
Enable Guides for 9-Slice Scaling 啟用 9-slice 縮放參考線
|
||||
Persistent 持久
|
||||
Library Panel Options 資料庫面板選項
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -751,6 +751,12 @@ impl VectorcraftApp {
|
||||
match &r {
|
||||
Err(e) => self.ui.status = e.clone(),
|
||||
Ok(v) => {
|
||||
// Image Trace's notes (a large image traces more slowly, or was scaled down).
|
||||
if id.starts_with("imageTrace.make")
|
||||
&& let Some(w) = v["warnings"].as_array().and_then(|w| w.first()).and_then(Value::as_str)
|
||||
{
|
||||
self.ui.status = w.to_string();
|
||||
}
|
||||
if id == "file.new" {
|
||||
self.ui.status.clear();
|
||||
// New Document's Pixel preview mode (Overprint Preview is the engine's).
|
||||
@@ -1387,6 +1393,7 @@ impl VectorcraftApp {
|
||||
if self.ui.toolbar && self.ui.screen_mode < 3 {
|
||||
toolbar::show(self, ui);
|
||||
}
|
||||
panels::library_panel::tidy(self);
|
||||
if self.ui.dock && self.ui.screen_mode < 3 {
|
||||
dock::show(self, ui);
|
||||
}
|
||||
@@ -1398,7 +1405,6 @@ impl VectorcraftApp {
|
||||
});
|
||||
dock::floating_panel(self, &ctx);
|
||||
floating::show(self, &ctx);
|
||||
panels::library_panel::show_window(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
palette::show(self, &ctx);
|
||||
if self.custom_titlebar {
|
||||
|
||||
+29
-18
@@ -495,13 +495,19 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"window.swatchLibrary",
|
||||
"Swatch Library",
|
||||
"",
|
||||
"{library: id or name (see swatch.library.list) | null (close)} open the read-only library panel on a swatch library (UI state `library_panel`); clicking a swatch there runs swatch.library.add with apply → {open, name, count}",
|
||||
"{library: id or name (see swatch.library.list) | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:<id>`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}",
|
||||
),
|
||||
(
|
||||
"window.swatchLibrary.other",
|
||||
"Other Library…",
|
||||
"",
|
||||
"{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in the library panel",
|
||||
"{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel",
|
||||
),
|
||||
(
|
||||
"ui.libraryPanel",
|
||||
"Library Panel Options",
|
||||
"",
|
||||
"{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}",
|
||||
),
|
||||
(
|
||||
"ui.saveSwatchLibrary",
|
||||
@@ -589,13 +595,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"window.graphicStyleLibrary",
|
||||
"Graphic Style Library",
|
||||
"",
|
||||
"{library: id or name (see graphicStyle.libraries) | null (close)} open the read-only library panel on a graphic style library (UI state `library_panel`, kind graphicStyles); clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add) → {open, name, count}",
|
||||
"{library: id or name (see graphicStyle.libraries) | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:<id>`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}",
|
||||
),
|
||||
(
|
||||
"window.graphicStyleLibrary.other",
|
||||
"Other Library…",
|
||||
"",
|
||||
"{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in the library panel",
|
||||
"{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel",
|
||||
),
|
||||
(
|
||||
"ui.saveGraphicStyleLibrary",
|
||||
@@ -992,10 +998,14 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
];
|
||||
|
||||
/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label,
|
||||
/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`).
|
||||
/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`), or a library panel's id
|
||||
/// as it is (`library:swatches:pastels`).
|
||||
pub(crate) fn normalize_panel(input: &str) -> Option<&'static str> {
|
||||
let name = input.trim();
|
||||
crate::state::all_panels().find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)).map(|(id, _)| id)
|
||||
crate::state::all_panels()
|
||||
.find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label))
|
||||
.map(|(id, _)| id)
|
||||
.or_else(|| crate::panels::library_panel::intern(input))
|
||||
}
|
||||
|
||||
/// An optional `{key?: bool}` param: none when it is omitted or null (the commands then toggle).
|
||||
@@ -1405,6 +1415,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
app.ui.dock_tab = tab;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// A library panel isn't in the icon column: it floats.
|
||||
(Some(p), None) if crate::panels::library_panel::parse(p).is_some() => crate::panels::library_panel::float(app, p),
|
||||
(Some(p), None) => {
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
|
||||
app.ui.dock = true;
|
||||
@@ -1538,6 +1550,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
}
|
||||
"ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p),
|
||||
"window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p),
|
||||
"ui.libraryPanel" => crate::panels::library_panel::command(app, p),
|
||||
"window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")),
|
||||
"ui.saveGraphicStyleLibrary" => {
|
||||
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
@@ -3763,7 +3776,7 @@ fn effect_menu() -> Vec<Item> {
|
||||
Sep,
|
||||
Item::Header("Vector Effects"),
|
||||
];
|
||||
// Submenus (and Crop Marks, an item of its own) in the reference app's order.
|
||||
// Submenus (and Crop Marks and Rasterize…, items of their own) in the reference app's order.
|
||||
let order = [
|
||||
"3D and Materials",
|
||||
"Color Adjustments",
|
||||
@@ -3772,6 +3785,7 @@ fn effect_menu() -> Vec<Item> {
|
||||
"Distort & Transform",
|
||||
"Path",
|
||||
"Pathfinder",
|
||||
"Rasterize…",
|
||||
"Stylize",
|
||||
"SVG Filters",
|
||||
"Warp",
|
||||
@@ -3782,21 +3796,18 @@ fn effect_menu() -> Vec<Item> {
|
||||
// Glowing Edges).
|
||||
let in_vector_menu =
|
||||
|e: &vectorcraft_effects::EffectInfo| order.iter().any(|s| e.menu == ["Effect", *s]) && !vectorcraft_effects::PIXEL_EFFECTS.contains(&e.id);
|
||||
let item = |e: &vectorcraft_effects::EffectInfo| match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
|
||||
// No options (Effect → Pathfinder, Crop Marks): apply directly, like Illustrator.
|
||||
true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })),
|
||||
false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })),
|
||||
};
|
||||
// The effects whose catalogue menu path is `path`.
|
||||
// `vector` picks the ones for the vector submenu or the ones for the raster submenu.
|
||||
let items_at = |path: &[&str], vector: bool| -> Vec<Item> {
|
||||
cat.iter()
|
||||
.filter(|e| e.menu == path && in_vector_menu(e) == vector)
|
||||
.map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
|
||||
// No options (Effect → Pathfinder): apply directly, like Illustrator.
|
||||
true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })),
|
||||
false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let items_at =
|
||||
|path: &[&str], vector: bool| -> Vec<Item> { cat.iter().filter(|e| e.menu == path && in_vector_menu(e) == vector).map(item).collect() };
|
||||
for sub_name in order {
|
||||
if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) {
|
||||
out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })));
|
||||
out.push(item(e));
|
||||
continue;
|
||||
}
|
||||
let items = items_at(&["Effect", sub_name], true);
|
||||
|
||||
@@ -625,6 +625,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
display
|
||||
.floating
|
||||
.retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id))));
|
||||
// Library panels are titled by their libraries' names.
|
||||
let library_labels: Vec<(String, String)> = layout
|
||||
.panels()
|
||||
.into_iter()
|
||||
.filter_map(|panel| crate::panels::library_panel::tab_label(app, panel).map(|label| (panel.clone(), label)))
|
||||
.collect();
|
||||
let library_label = |panel: &str| library_labels.iter().find(|(p, _)| p == panel).map(|(_, label)| label.clone());
|
||||
// Floating groups lie on the canvas, not on the dock's card: reserve a fill under each one
|
||||
// before the dock draws it, sized once it is placed, so the canvas doesn't show through (#2421).
|
||||
let fills: Vec<_> = display
|
||||
@@ -646,8 +653,16 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
let info = crate::state::DockTab::from_id(panel)
|
||||
.map(|tab| tab.info())
|
||||
.or_else(|| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == panel).copied());
|
||||
if let Some((_, label, icon)) = info
|
||||
&& crate::widgets::icon_button(ui, icon, tl!(label), app.ui.open_panel.as_ref() == Some(panel), 30.0).clicked()
|
||||
let label = info.map(|(_, label, _)| tl!(label).to_string()).or_else(|| library_label(panel));
|
||||
if let Some(label) = label
|
||||
&& crate::widgets::icon_button(
|
||||
ui,
|
||||
info.map_or("library", |(_, _, icon)| icon),
|
||||
&label,
|
||||
app.ui.open_panel.as_ref() == Some(panel),
|
||||
30.0,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.ui.open_panel = if app.ui.open_panel.as_ref() == Some(panel) { None } else { Some(panel.clone()) };
|
||||
}
|
||||
@@ -675,7 +690,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
ui,
|
||||
&display,
|
||||
&style,
|
||||
|id| crate::state::all_panels().find(|p| p.0 == id).map(|p| tl!(p.1).to_string()).unwrap_or_else(|| id.clone()),
|
||||
|id| {
|
||||
crate::state::all_panels()
|
||||
.find(|p| p.0 == id)
|
||||
.map(|p| tl!(p.1).to_string())
|
||||
.or_else(|| library_label(id))
|
||||
.unwrap_or_else(|| id.clone())
|
||||
},
|
||||
|_| craft_ui::docking::Permissions::default(),
|
||||
|_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() },
|
||||
&mut content,
|
||||
|
||||
@@ -640,3 +640,35 @@ fn floating_group_paints_an_opaque_background() {
|
||||
assert!(opaque_at(pos2(690.0, 210.0)), "tab strip is transparent");
|
||||
assert!(opaque_at(pos2(690.0, 610.0)), "body is transparent");
|
||||
}
|
||||
|
||||
/// Library panels (#2603) float in the shared layout titled by their libraries' names, and their
|
||||
/// menu's Persistent item keeps them for the next launch.
|
||||
#[test]
|
||||
fn library_panels_float_with_their_names_and_a_persistent_menu_item() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
for (library, x) in [("pastels", 40), ("brights", 420)] {
|
||||
let panel = format!("library:swatches:{library}");
|
||||
app.run("window.swatchLibrary", json!({"library": library})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel": panel, "x": x, "y": 100})).unwrap();
|
||||
}
|
||||
let mut h = harness(app, vec2(1280.0, 900.0));
|
||||
for name in ["Pastels", "Brights"] {
|
||||
assert!(h.query_all_by_label(name).next().is_some(), "the {name} tab");
|
||||
}
|
||||
let click = |h: &mut Harness<'static, VectorcraftApp>, at: Pos2| {
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
};
|
||||
let menu = h.query_all_by_label("Panel menu").find(|node| node.rect().center().x < 400.0).expect("the Pastels panel's menu").rect().center();
|
||||
click(&mut h, menu);
|
||||
let item = h.query_all_by_label(" Persistent").next().expect("Persistent is in the panel menu").rect().center();
|
||||
click(&mut h, item);
|
||||
let persistent: Vec<&str> = h.state().ui.library_panels.iter().filter(|p| p.persistent).map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(persistent, ["pastels"]);
|
||||
}
|
||||
@@ -533,6 +533,72 @@ pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Variable Font button, `width` wide, shown when the font of style `s` is a variable font: it
|
||||
/// opens the font's axis sliders ([`axis_sliders`]).
|
||||
fn variable_font(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, width: f32) {
|
||||
let Some(face) = vectorcraft_text::FontDb::global().face_version(&s.font_family, &s.font_style, s.font_version.as_deref()) else { return };
|
||||
if face.axes().iter().all(|a| a.hidden) {
|
||||
return;
|
||||
}
|
||||
let resp = widgets::flat_button(ui, tl!("Variable Font"), width);
|
||||
widgets::popover(&resp, resp.clicked(), |ui| {
|
||||
ui.set_width(240.0);
|
||||
axis_sliders(app, ui, &face);
|
||||
});
|
||||
}
|
||||
|
||||
/// One slider and field per axis of `face`'s variable font (the axes it asks to hide left out),
|
||||
/// named as the font names it: each sets that axis of the text the panel acts on
|
||||
/// (`text.setFormat {fontAxes}`, or `text.setRangeStyle` while the Type tool edits text), one undo
|
||||
/// step per drag. A blank field: the text's settings of that axis differ.
|
||||
pub(crate) fn axis_sliders(app: &mut VectorcraftApp, ui: &mut Ui, face: &vectorcraft_text::FontFace) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
for axis in face.axes().into_iter().filter(|a| !a.hidden) {
|
||||
let tag = String::from_utf8_lossy(&axis.tag).into_owned();
|
||||
// Each character style's setting: its own, else its face's (a named instance's, or the default).
|
||||
let setting = |s: &CharStyle| {
|
||||
s.font_axes.get(&tag).copied().or_else(|| {
|
||||
let face = db.face_version(&s.font_family, &s.font_style, s.font_version.as_deref())?;
|
||||
face.axis_value(axis.tag).map(f64::from)
|
||||
})
|
||||
};
|
||||
let value = shared(app, setting).flatten();
|
||||
let (min, max) = (f64::from(axis.min.min(axis.max)), f64::from(axis.max.max(axis.min)));
|
||||
ui.horizontal(|ui| {
|
||||
let label = widgets::dim_name(ui, &axis.name);
|
||||
crate::scrub::note_label(ui, label.rect);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(v) = widgets::spin_plain(ui, ("ch-axis-field", axis.tag), value, "", 1, 72.0, 1.0, min, &[]) {
|
||||
axes_cmd(app, json!({ &tag: v.clamp(min, max) }), widgets::Live::Released);
|
||||
}
|
||||
});
|
||||
});
|
||||
let at = value.map_or(0.0, |v| if max > min { ((v - min) / (max - min)) as f32 } else { 0.0 });
|
||||
let (picked, phase) = widgets::color_slider_named(ui, ("ch-axis", axis.tag), &axis.name, at, ui.available_width(), &|_| t.button_border);
|
||||
if let Some(p) = picked {
|
||||
let v = ((min + f64::from(p) * (max - min)) * 10.0).round() / 10.0;
|
||||
axes_cmd(app, json!({ &tag: v }), phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set Variable Font settings `axes` on the text the panel acts on, live while a slider drags.
|
||||
fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) {
|
||||
match text_editing(app) {
|
||||
Some((id, a, b)) => {
|
||||
end_typing(app);
|
||||
let mut q = json!({"id": id.0, "fontAxes": axes});
|
||||
if b > a {
|
||||
q["start"] = json!(a);
|
||||
q["end"] = json!(b);
|
||||
}
|
||||
super::live_run(app, "Character", "text.setRangeStyle", q, phase);
|
||||
}
|
||||
None => super::live_run(app, "Character", "text.setFormat", json!({ "fontAxes": axes }), phase),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Control bar's type controls while type is selected or edited: the Character link (the
|
||||
/// Character panel in a popover), the font family and style menus and the Font Size combo.
|
||||
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
@@ -557,6 +623,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
};
|
||||
let w = ui.available_width();
|
||||
font_pickers(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0));
|
||||
variable_font(app, ui, &s, w - 4.0);
|
||||
ui.add_space(2.0);
|
||||
let hidden = pstate::<bool>(ui.ctx(), "ch-hide-options");
|
||||
metrics_grid(app, ui, "ch-grid", &s, w, !hidden);
|
||||
@@ -860,6 +927,75 @@ mod tests {
|
||||
assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0)));
|
||||
}
|
||||
|
||||
/// A variable font gets the Variable Font button, which opens one slider per axis named as the
|
||||
/// font names it; a drag along one sets that axis of the selected type in one undo step.
|
||||
#[test]
|
||||
fn variable_fonts_get_a_slider_per_axis() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
db.add_font(two_axis_font().unwrap());
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let plain = app.run("text.create", json!({"x": 20, "y": 50, "text": "Static"})).unwrap()["id"].clone();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 120, "text": "lIn", "font": TWO_AXIS_FAMILY})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [plain]})).unwrap();
|
||||
assert!(!crate::tests_labels::painted_text(&mut app, show).contains("Variable Font"), "a static font");
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
assert!(crate::tests_labels::painted_text(&mut app, show).contains("Variable Font"));
|
||||
let face = db.face(TWO_AXIS_FAMILY, "Regular").unwrap();
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face));
|
||||
assert!(text.contains("Weight\n") && text.contains("Width\n") && text.contains("400") && text.contains("100"), "{text}");
|
||||
// The labels are the font's names in every interface language, not catalog words (ja
|
||||
// translates the interface's Weight as 線幅, the stroke weight).
|
||||
crate::i18n::set_current(crate::i18n::Lang::from_code("ja").unwrap());
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face));
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
assert!(text.contains("Weight\n") && text.contains("Width\n") && !text.contains("線幅"), "{text}");
|
||||
// Screen readers name each slider by its axis.
|
||||
let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face));
|
||||
for name in ["Weight", "Width"] {
|
||||
assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::Slider && n.label() == Some(name)), "{name}");
|
||||
}
|
||||
// Drag the Weight slider from its start to past its end.
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut slider = None;
|
||||
let input = egui::RawInput { events, ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
axis_sliders(app, ui, &face);
|
||||
slider = ctx.read_response(ui.id().with(("ch-axis", *b"wght"))).map(|r| r.rect);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
slider
|
||||
};
|
||||
frame(&mut app, vec![]);
|
||||
let r = frame(&mut app, vec![]).expect("the Weight slider is drawn");
|
||||
let none = egui::Modifiers::NONE;
|
||||
let (from, to) = (egui::pos2(r.left() + 4.0, r.center().y), egui::pos2(r.right() + 20.0, r.center().y));
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: none };
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||||
for i in 1..=4 {
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(from + (to - from) * i as f32 / 4.0)]);
|
||||
}
|
||||
frame(&mut app, vec![button(to, false)]);
|
||||
frame(&mut app, vec![]);
|
||||
let axes = |app: &VectorcraftApp| shared(app, |s| s.font_axes.clone()).unwrap();
|
||||
assert_eq!(axes(&app).into_iter().collect::<Vec<_>>(), [("wght".to_string(), 700.0)]);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(axes(&app).is_empty(), "one undo step");
|
||||
// Regular and Bold selected: the weights differ and the field is blank; the widths agree.
|
||||
let bold = app.run("text.create", json!({"x": 20, "y": 160, "text": "lIn", "font": TWO_AXIS_FAMILY, "style": "Bold"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [id, bold]})).unwrap();
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face));
|
||||
assert!(!text.contains("400") && !text.contains("700") && text.contains("100"), "{text}");
|
||||
// Reset Panel leaves the settings, as it leaves the font and style.
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("text.setFormat", json!({"fontAxes": {"wght": 650}})).unwrap();
|
||||
reset_panel(&mut app);
|
||||
assert_eq!(axes(&app).into_iter().collect::<Vec<_>>(), [("wght".to_string(), 650.0)]);
|
||||
}
|
||||
|
||||
/// Bold and Italic pick the family's own faces by name, and toggle back (#724).
|
||||
#[test]
|
||||
fn bold_and_italic_pick_the_familys_own_faces() {
|
||||
|
||||
@@ -103,7 +103,7 @@ fn limit_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
chosen = library_panel::library_items(ui, &colour_libraries(app), Some(&cur)).or(chosen);
|
||||
chosen = library_panel::library_items(ui, &colour_libraries(app), &|id| id == cur).or(chosen);
|
||||
if let Some(key) = chosen {
|
||||
set_limit(app, &json!({ "library": key })).ok();
|
||||
clear_selection(ui.ctx());
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! to the art it is dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance
|
||||
//! panel's thumbnail dropped on the panel becomes a new style. Holding the right button on a style
|
||||
//! shows it large, on the selected object when there is one. The Control bar's Style picker lists
|
||||
//! the same tiles ([`picker`]). Graphic style libraries open in the library panel
|
||||
//! the same tiles ([`picker`]). Graphic style libraries open in library panels
|
||||
//! ([`GraphicStyleLibraries`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
@@ -479,7 +479,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let has_sel = selection_len(app) > 0;
|
||||
let linked = app.session.selection_graphic_style().is_some();
|
||||
widgets::bottom_bar(ui, |ui| {
|
||||
let open = app.ui.library_panel.as_ref().is_some_and(|o| o.kind == GraphicStyleLibraries::KIND);
|
||||
let open = library_panel::any_open::<GraphicStyleLibraries>(&app.ui);
|
||||
let lr = widgets::icon_button(ui, "library", GraphicStyleLibraries::MENU, open, 24.0);
|
||||
egui::Popup::menu(&lr).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
@@ -681,13 +681,15 @@ fn describe(g: &GraphicStyle) -> String {
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
/// Graphic style libraries in the library panel (engine: `graphicStyle.libraries`,
|
||||
/// Graphic style libraries in library panels (engine: `graphicStyle.libraries`,
|
||||
/// `graphicStyle.addFromLibrary`…).
|
||||
pub(crate) struct GraphicStyleLibraries;
|
||||
|
||||
impl LibraryKind for GraphicStyleLibraries {
|
||||
const KIND: &'static str = "graphicStyles";
|
||||
const OPEN: &'static str = "window.graphicStyleLibrary";
|
||||
const LIST: &'static str = "graphicStyle.libraries";
|
||||
const LOAD: &'static str = "graphicStyle.loadLibrary";
|
||||
const MENU: &'static str = "Graphic Style Libraries Menu";
|
||||
const ADD: &'static str = "Add to Graphic Styles";
|
||||
/// Styles need room to show their effects.
|
||||
@@ -702,6 +704,12 @@ impl LibraryKind for GraphicStyleLibraries {
|
||||
fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> {
|
||||
stylelib::library(&app.session, id).map(|(info, lib)| (info.name, shown_library(lib)))
|
||||
}
|
||||
fn find(app: &VectorcraftApp, key: &str) -> Option<(String, String, usize)> {
|
||||
stylelib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len()))
|
||||
}
|
||||
fn path(app: &VectorcraftApp, id: &str) -> Option<String> {
|
||||
app.session.style_libraries.path(id).map(str::to_string)
|
||||
}
|
||||
fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec<Row<'a, LibStyle>> {
|
||||
let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query);
|
||||
lib.iter().filter(|s| found(&s.style().name)).map(|s| Row { name: &s.style().name, item: Some(s) }).collect()
|
||||
@@ -740,15 +748,13 @@ impl LibraryKind for GraphicStyleLibraries {
|
||||
}
|
||||
}
|
||||
|
||||
/// `window.graphicStyleLibrary {library}`: open a graphic style library in the library panel
|
||||
/// (`library` null closes it).
|
||||
/// `window.graphicStyleLibrary {library}`: open a graphic style library in its own panel
|
||||
/// (`library` null closes every graphic style library panel).
|
||||
pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
library_panel::open_command::<GraphicStyleLibraries>(app, p, "graphicStyle.libraries", |app, key| {
|
||||
stylelib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len()))
|
||||
})
|
||||
library_panel::open_command::<GraphicStyleLibraries>(app, p)
|
||||
}
|
||||
|
||||
/// `graphicStyle.loadLibrary` params, then open the library in the panel.
|
||||
/// `graphicStyle.loadLibrary` params, then open the library in its panel.
|
||||
pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||||
let r = app.run("graphicStyle.loadLibrary", params)?;
|
||||
open_library(app, &json!({ "library": r["library"] }))
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user