Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f043a1a392 | ||
|
|
d01d7b2ef0 | ||
|
|
dc6e8a4253 | ||
|
|
327737b21a | ||
|
|
a7a4d952ea | ||
|
|
621e1a6640 | ||
|
|
c5274beb23 | ||
|
|
611f6855ef | ||
|
|
75c587c8a0 | ||
|
|
10f1c56a86 | ||
|
|
ab49f6af8b | ||
|
|
51e24f2944 |
No files matched your search
@@ -377,11 +377,91 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
# The riscv64 variants of the linux job's -dev packages.
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
|
||||
desktop-file-utils appstream
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
|
||||
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
|
||||
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the package
|
||||
run: |
|
||||
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
|
||||
# drafts a release.
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
Generated
+27
-27
@@ -4320,7 +4320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4350,7 +4350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4361,7 +4361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4373,7 +4373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4389,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4410,7 +4410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4422,7 +4422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4439,7 +4439,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4454,7 +4454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4489,7 +4489,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4509,7 +4509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4523,7 +4523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4535,7 +4535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4554,7 +4554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4568,7 +4568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4580,7 +4580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4609,7 +4609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4623,7 +4623,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4648,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4672,7 +4672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4689,7 +4689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4707,14 +4707,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4729,7 +4729,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4742,7 +4742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4773,7 +4773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5883,7 +5883,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). |
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). |
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.lollinoo]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
|
||||
@@ -95,6 +95,11 @@ pub struct CharStyle {
|
||||
/// their line line up with it.
|
||||
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub char_align: CharAlign,
|
||||
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
|
||||
/// font's own proportional widths, OpenType `palt` (#966). Vertical type sets them as before
|
||||
/// for now.
|
||||
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub proportional_metrics: bool,
|
||||
}
|
||||
|
||||
/// Where a character smaller than the largest on its line lines up with it: on the Roman
|
||||
@@ -205,6 +210,7 @@ impl Default for CharStyle {
|
||||
stroke_dash: None,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
small_caps: None,
|
||||
}
|
||||
}
|
||||
@@ -954,7 +960,49 @@ pub struct TextObject {
|
||||
pub cached_baselines: Vec<(Point, Point)>,
|
||||
}
|
||||
|
||||
/// Conversion from stored character attributes to document-space type controls.
|
||||
/// Point size follows the transformed em-height axis; horizontal scale is the ratio of
|
||||
/// the two transformed em axes. Rotation, reflection and shear remain in the affine.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TextStyleScale {
|
||||
pub points: f64,
|
||||
pub horizontal: f64,
|
||||
}
|
||||
|
||||
impl TextObject {
|
||||
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
|
||||
/// be edited by compensating the stored attributes.
|
||||
pub fn style_scale(&self) -> Option<TextStyleScale> {
|
||||
let [a, b, c, d, e, f] = self.xf.as_coeffs();
|
||||
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (a.hypot(b), c.hypot(d));
|
||||
let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy);
|
||||
let horizontal = sx / sy;
|
||||
if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// Avoid a pure rotation turning otherwise identical fields into mixed values.
|
||||
let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v };
|
||||
Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) })
|
||||
}
|
||||
|
||||
/// Character attributes as presented by controls (stored runs stay local for layout
|
||||
/// and rich-text clipboard operations). Explicit vertical character scale stays separate.
|
||||
pub fn effective_char_style(&self, style: &CharStyle) -> Option<CharStyle> {
|
||||
let scale = self.style_scale()?;
|
||||
let mut style = style.clone();
|
||||
style.size *= scale.points;
|
||||
style.leading = style.leading.map(|v| v * scale.points);
|
||||
style.baseline_shift *= scale.points;
|
||||
style.h_scale *= scale.horizontal;
|
||||
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(style)
|
||||
}
|
||||
|
||||
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
|
||||
Self {
|
||||
vertical: false,
|
||||
@@ -1190,6 +1238,19 @@ impl TextObject {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] {
|
||||
t.xf = Affine::new(matrix);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none());
|
||||
}
|
||||
t.xf = Affine::rotate(0.83);
|
||||
assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0);
|
||||
t.xf = Affine::scale(1e308);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_text_basics() {
|
||||
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
|
||||
|
||||
+166
-19
@@ -251,17 +251,26 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
|
||||
let areas = bool_or(p, "typeAreas", false);
|
||||
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||
sc.patterns = transform_patterns(s, p, &ids)?;
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& !(areas && resize_type_area(n, xf))
|
||||
{
|
||||
n.transform(xf, sc);
|
||||
// A transform of ids that name no object, or an identity transform without a copy, moves
|
||||
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
|
||||
// With a copy, the reply lists the copies, and there are none.
|
||||
let d = &s.doc()?.doc;
|
||||
let none = ids.iter().all(|id| d.node(*id).is_none());
|
||||
let ids = if none || (!copy && xf == Affine::IDENTITY) {
|
||||
if copy { vec![] } else { ids }
|
||||
} else {
|
||||
s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& !(areas && resize_type_area(n, xf))
|
||||
{
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(targets)
|
||||
})?;
|
||||
Ok(targets)
|
||||
})?
|
||||
};
|
||||
let st = s.doc_mut()?;
|
||||
if st.interaction.is_none() {
|
||||
st.last_transform = Some((xf, copy));
|
||||
@@ -300,10 +309,19 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dx = f64_or(p, "dx", 0.0) * k;
|
||||
let dy = f64_or(p, "dy", 0.0) * k;
|
||||
if !s.doc()?.selection.anchors.is_empty() {
|
||||
// When the anchors move by 0, the document and its undo history stay as they are.
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return ok();
|
||||
}
|
||||
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
|
||||
}
|
||||
if s.doc()?.selection.is_empty() {
|
||||
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) }));
|
||||
let copy = bool_or(p, "copy", false);
|
||||
// When guides move by 0 without a copy, the document and its undo history stay as they are.
|
||||
if dx == 0.0 && dy == 0.0 && !copy {
|
||||
return ok();
|
||||
}
|
||||
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
|
||||
}
|
||||
let ids = selected_roots(s)?;
|
||||
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p)
|
||||
@@ -386,7 +404,9 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
|
||||
Arrange::Backward => "Send Backward",
|
||||
Arrange::Back => "Send to Back",
|
||||
};
|
||||
s.edit(label, |d, _| {
|
||||
// When nothing changes order, the document and its undo history stay as they are.
|
||||
super::overprint::edit_counted(s, label, |d| {
|
||||
let mut moved = 0;
|
||||
// Arrange within each parent independently (selections may span layers/groups).
|
||||
let mut by_parent: Vec<(Option<vectorcraft_doc::NodeId>, Vec<vectorcraft_doc::NodeId>)> = vec![];
|
||||
for id in &ids {
|
||||
@@ -411,10 +431,11 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
|
||||
};
|
||||
if to != idx {
|
||||
d.move_node(*id, par, to)?;
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(moved)
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -424,6 +445,11 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// Ids are reused after undo: the remembered current layer must still be a layer.
|
||||
let st = s.doc()?;
|
||||
let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?;
|
||||
// When the selected objects are already the topmost in the current layer, in paint order, the
|
||||
// document and its undo history stay as they are.
|
||||
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Send to Current Layer", |d, _| {
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(layer), usize::MAX)?;
|
||||
@@ -457,6 +483,11 @@ pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
|
||||
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no group selected, the document, the selection and the undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Ungroup", |d, sel| {
|
||||
let mut new_sel = vec![];
|
||||
for id in &ids {
|
||||
@@ -492,6 +523,13 @@ fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn lock(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With every selected object already locked, the document and its undo history stay as they
|
||||
// are, and the objects are deselected.
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) {
|
||||
s.select(|_, sel| sel.clear())?;
|
||||
return ok();
|
||||
}
|
||||
s.edit("Lock", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -516,6 +554,10 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
|
||||
fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
|
||||
// With nothing locked, the document, the selection and the undo history stay as they are.
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Unlock All", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -530,6 +572,13 @@ fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn hide(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With every selected object already hidden, the document and its undo history stay as they
|
||||
// are, and the objects are deselected.
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) {
|
||||
s.select(|_, sel| sel.clear())?;
|
||||
return ok();
|
||||
}
|
||||
s.edit("Hide", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -544,6 +593,10 @@ fn hide(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
|
||||
// With nothing hidden, the document, the selection and the undo history stay as they are.
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Show All", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -602,6 +655,12 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no compound path selected, the document, the selection and the undo history stay as
|
||||
// they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release Compound Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
@@ -678,6 +737,11 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no clip group selected, the document and its undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release Clipping Mask", |d, _| {
|
||||
for id in &ids {
|
||||
release_clip(d, *id);
|
||||
@@ -785,6 +849,27 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// When every object already has every value given, the document and its undo history stay as
|
||||
// they are.
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let has_values = |n: &Node| {
|
||||
str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty()))
|
||||
&& flag("visible").is_none_or(|v| n.visible == v)
|
||||
&& flag("locked").is_none_or(|v| n.locked == v)
|
||||
&& opacity.is_none_or(|v| n.opacity == v)
|
||||
&& blend.is_none_or(|b| n.blend == b)
|
||||
&& flag("isolate").is_none_or(|v| n.isolate == v)
|
||||
&& knockout.is_none_or(|k| n.knockout == k)
|
||||
&& flag("knockoutShape").is_none_or(|v| n.knockout_shape == v)
|
||||
&& data
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref())
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Object Properties", |d, _| {
|
||||
for id in &ids {
|
||||
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
@@ -864,6 +949,11 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec
|
||||
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
|
||||
}
|
||||
|
||||
/// Is every move in `moves` a rounding error (1e-9 pt or less on both axes)?
|
||||
fn moves_nothing(moves: &[(NodeId, Vec2)]) -> bool {
|
||||
moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
|
||||
}
|
||||
|
||||
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
|
||||
/// selection.
|
||||
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
|
||||
@@ -915,6 +1005,10 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// When nothing moves, the document and its undo history stay as they are.
|
||||
if moves_nothing(&moves) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Align", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -965,6 +1059,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
|
||||
})
|
||||
.collect();
|
||||
// When nothing moves, the document and its undo history stay as they are.
|
||||
if moves_nothing(&moves) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Distribute", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -1008,6 +1106,10 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
|
||||
let moves: Vec<(NodeId, Vec2)> =
|
||||
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
|
||||
// When nothing moves, the document and its undo history stay as they are.
|
||||
if moves_nothing(&moves) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Distribute Spacing", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -1057,12 +1159,23 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
|
||||
// x, y place the reference point on the page.
|
||||
let page = frame.to_doc() * rp;
|
||||
// When every value given is the current one, the transform is the identity. Computing it can
|
||||
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
|
||||
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
|
||||
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
|
||||
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p);
|
||||
}
|
||||
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
|
||||
}
|
||||
|
||||
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no live shape selected, the document and its undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Expand Shape", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
@@ -1138,6 +1251,45 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
|
||||
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
|
||||
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
|
||||
// An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole
|
||||
// ellipse as 0° to 360°).
|
||||
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
|
||||
let pie_of = |pie: (f64, f64)| {
|
||||
let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
|
||||
if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie }
|
||||
};
|
||||
// When every value given is the current one, the document and its undo history stay as they
|
||||
// are.
|
||||
let st = s.doc()?;
|
||||
let has_values = |id: &NodeId| {
|
||||
let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true };
|
||||
match live {
|
||||
Some(l @ LiveShape::Polygon { sides: now, .. })
|
||||
if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now))
|
||||
&& (!equal || l.polygon_sides_equal())
|
||||
&& polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a))
|
||||
&& side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) =>
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
|
||||
_ => {}
|
||||
}
|
||||
if radius.is_none() && kind.is_none() {
|
||||
return true;
|
||||
}
|
||||
let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true };
|
||||
let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id)));
|
||||
// Setting corners folds a scale left in a rectangle's transform into its size, which
|
||||
// changes it.
|
||||
live.as_ref().is_none_or(|l| l.folded() == *l)
|
||||
&& picked
|
||||
.iter()
|
||||
.all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd))
|
||||
};
|
||||
if ids.iter().all(has_values) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Live Shape", |d, sel| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
@@ -1163,12 +1315,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& (pie_start.is_some() || pie_end.is_some() || invert)
|
||||
{
|
||||
if let LiveShape::Ellipse { pie, .. } = l {
|
||||
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
|
||||
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
|
||||
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
|
||||
if invert {
|
||||
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
|
||||
}
|
||||
*pie = pie_of(*pie);
|
||||
}
|
||||
*path = l.to_path();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -375,6 +375,34 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
|
||||
/// remember the paint.
|
||||
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||||
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
|
||||
if p.get("ids").is_none()
|
||||
&& p.get("item").is_none()
|
||||
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
|
||||
&& a != b
|
||||
{
|
||||
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let (xf, lb) = (t.xf, t.local_bounds());
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
||||
if fill {
|
||||
st.fill = place_run_paint(&paint, p, xf, lb);
|
||||
} else {
|
||||
if !paint.is_none() {
|
||||
run_stroke_weight(st);
|
||||
}
|
||||
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
|
||||
}
|
||||
});
|
||||
super::typecmd::refresh_bounds(t);
|
||||
Ok(())
|
||||
})?;
|
||||
if bool_or(p, "focus", true) {
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
return ok();
|
||||
}
|
||||
// Resolve the requested targets before mutating defaults or the active proxy.
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
let selected = item.targets(s, p)?;
|
||||
|
||||
@@ -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, 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), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), 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`; horizontal type), 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
|
||||
),
|
||||
@@ -185,14 +185,7 @@ const MAX_PARA: f64 = 1296.0;
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setFormat";
|
||||
let ids: Vec<NodeId> = {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no text objects selected"));
|
||||
}
|
||||
let ids = super::typecmd::text_targets(s, p, C)?;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let kerning = match p.get("kerning") {
|
||||
@@ -222,6 +215,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"direction",
|
||||
"leadingModel",
|
||||
"charAlign",
|
||||
"proportionalMetrics",
|
||||
"burasagari",
|
||||
"composer",
|
||||
];
|
||||
@@ -285,15 +279,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?;
|
||||
let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?;
|
||||
super::typecmd::style_chars(t, range, |st| {
|
||||
if let Some(k) = kerning {
|
||||
st.kerning = k;
|
||||
}
|
||||
if let Some(v) = num("baselineShift") {
|
||||
st.baseline_shift = v.clamp(-1296.0, 1296.0);
|
||||
if let Some(v) = baseline_shift {
|
||||
st.baseline_shift = v;
|
||||
}
|
||||
if let Some(v) = num("hScale") {
|
||||
st.h_scale = v.clamp(1.0, 10000.0);
|
||||
if let Some(v) = h_scale {
|
||||
st.h_scale = v;
|
||||
}
|
||||
if let Some(v) = num("vScale") {
|
||||
st.v_scale = v.clamp(1.0, 10000.0);
|
||||
@@ -319,6 +315,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = flag("proportionalMetrics") {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
});
|
||||
let span = super::typecmd::para_span(range, t);
|
||||
t.edit_paras(span, |para| {
|
||||
|
||||
@@ -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?, 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\"} style a character range (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?, 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`; horizontal type)} 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
|
||||
),
|
||||
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Get Text Range",
|
||||
[],
|
||||
None,
|
||||
"{id, start?, end?} → {text, runs: [{text, style}], length}",
|
||||
"{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
|
||||
has_doc,
|
||||
get_range
|
||||
),
|
||||
@@ -174,7 +174,12 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = text_ref(s, id)?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let (a, b) = range_of(p, len);
|
||||
let runs = edit::slice_runs(&t.runs, a, b);
|
||||
let mut runs = edit::slice_runs(&t.runs, a, b);
|
||||
if p.get("effective").and_then(Value::as_bool).unwrap_or(false) {
|
||||
for run in &mut runs {
|
||||
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
|
||||
}
|
||||
}
|
||||
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
|
||||
Ok(json!({"text": text, "runs": runs, "length": len}))
|
||||
}
|
||||
@@ -203,6 +208,7 @@ pub(crate) struct CharChange {
|
||||
position: Option<vectorcraft_doc::CharPosition>,
|
||||
small_caps: Option<Option<f64>>,
|
||||
char_align: Option<vectorcraft_doc::CharAlign>,
|
||||
proportional_metrics: Option<bool>,
|
||||
}
|
||||
|
||||
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
|
||||
@@ -286,6 +292,7 @@ impl CharChange {
|
||||
position,
|
||||
small_caps,
|
||||
char_align: char_align_param(p, cmd)?,
|
||||
proportional_metrics: flag("proportionalMetrics"),
|
||||
};
|
||||
if c.size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(cmd, "size must be positive"));
|
||||
@@ -314,6 +321,16 @@ impl CharChange {
|
||||
&& self.position.is_none()
|
||||
&& self.small_caps.is_none()
|
||||
&& self.char_align.is_none()
|
||||
&& self.proportional_metrics.is_none()
|
||||
}
|
||||
|
||||
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
|
||||
use super::typecmd::local_type_value;
|
||||
self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?;
|
||||
self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?;
|
||||
self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
|
||||
self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, st: &mut CharStyle) {
|
||||
@@ -338,10 +355,10 @@ impl CharChange {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
st.size = v;
|
||||
}
|
||||
if let Some(v) = self.leading {
|
||||
st.leading = v.map(|l| l.clamp(0.1, 5000.0));
|
||||
st.leading = v;
|
||||
}
|
||||
if let Some(v) = self.tracking {
|
||||
st.tracking = v.clamp(-1000.0, 10000.0);
|
||||
@@ -350,10 +367,10 @@ impl CharChange {
|
||||
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
|
||||
}
|
||||
if let Some(v) = self.baseline_shift {
|
||||
st.baseline_shift = v.clamp(-1296.0, 1296.0);
|
||||
st.baseline_shift = v;
|
||||
}
|
||||
if let Some(v) = self.h_scale {
|
||||
st.h_scale = v.clamp(1.0, 10000.0);
|
||||
st.h_scale = v;
|
||||
}
|
||||
if let Some(v) = self.v_scale {
|
||||
st.v_scale = v.clamp(1.0, 10000.0);
|
||||
@@ -391,6 +408,9 @@ impl CharChange {
|
||||
if let Some(v) = self.char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = self.proportional_metrics {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,12 +465,13 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
|
||||
},
|
||||
};
|
||||
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
if change.is_empty() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
|
||||
let t = text_ref(s, id)?;
|
||||
change.localize(&t, C)?;
|
||||
let (a, b) = range_of(p, edit::runs_len(&t.runs));
|
||||
let n = s.edit("Character", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
@@ -542,7 +563,7 @@ enum Step {
|
||||
}
|
||||
|
||||
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
|
||||
fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
|
||||
pub(crate) fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
|
||||
let o = s.tool_options();
|
||||
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
|
||||
if super::edit::typing_in_progress(s) {
|
||||
@@ -605,13 +626,27 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Character", |d, _| {
|
||||
for &(id, a, b) in &ranges {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
edit::style_range(&mut t.runs, a, b, |st| match step {
|
||||
Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0),
|
||||
Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)),
|
||||
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
|
||||
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
|
||||
Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0),
|
||||
});
|
||||
let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) {
|
||||
t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mut runs = edit::slice_runs(&t.runs, a, b);
|
||||
for run in &mut runs {
|
||||
let st = &mut run.style;
|
||||
match step {
|
||||
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
|
||||
Step::Leading => {
|
||||
st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?);
|
||||
}
|
||||
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
|
||||
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
|
||||
Step::BaselineShift => {
|
||||
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
edit::replace_range_styled(&mut t.runs, a, b, &runs);
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -289,6 +289,22 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
|
||||
/// the local result would make small sizes impossible after enlarging an object.
|
||||
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
|
||||
if !value.is_finite() {
|
||||
return Err(bad(cmd, "type dimension must be finite"));
|
||||
}
|
||||
let scale = t.style_scale().ok_or_else(|| bad(cmd, "text transform is collapsed or unrepresentable"))?;
|
||||
let local = value.clamp(limits.0, limits.1) / if horizontal { scale.horizontal } else { scale.points };
|
||||
// Bound local dimensions too: an extreme inverse scale can produce a finite size
|
||||
// whose leading, glyph advances or layout bounds overflow before the edit's geometry check.
|
||||
if !local.is_finite() || local.abs() > crate::MAX_COORD || (value != 0.0 && local == 0.0) {
|
||||
return Err(bad(cmd, "type dimension cannot be represented with this text transform"));
|
||||
}
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
|
||||
/// paragraphs it touches. Without either, the command applies to all the text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -415,6 +431,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
|
||||
let leading = leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, C)).transpose()).transpose()?;
|
||||
let before = protect.then(|| t.runs.clone());
|
||||
style_chars(t, range, |st| {
|
||||
if let Some(f) = &font {
|
||||
@@ -428,7 +446,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
st.size = v;
|
||||
}
|
||||
if let Some(l) = leading {
|
||||
st.leading = l;
|
||||
@@ -841,6 +859,34 @@ mod area_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966): a character attribute, saved only when on.
|
||||
#[test]
|
||||
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
|
||||
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": "雅楽"})).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"),
|
||||
};
|
||||
assert!(!runs(&s)[0].style.proportional_metrics);
|
||||
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.proportional_metrics));
|
||||
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!runs(&s)[0].style.proportional_metrics);
|
||||
// Characters selected with the Type tool: only the range takes it (雅 is 3 bytes).
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap();
|
||||
let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect();
|
||||
assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]);
|
||||
// Styles written without it (documents from before it) read as off.
|
||||
let old = serde_json::to_value(&runs(&s)[0].style).unwrap();
|
||||
assert!(old.get("proportionalMetrics").is_none());
|
||||
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_type_is_composed_with_line_end_half_width_punctuation() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
|
||||
@@ -1324,6 +1324,11 @@ impl Session {
|
||||
{
|
||||
let st = self.doc_mut()?;
|
||||
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
|
||||
// Undoing the previous preview counts as a change, so views redraw even when the command
|
||||
// below returns without an edit.
|
||||
if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection {
|
||||
st.revision += 1;
|
||||
}
|
||||
st.doc = it.doc.clone();
|
||||
st.selection = it.selection.clone();
|
||||
}
|
||||
@@ -1686,6 +1691,8 @@ mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_typearea;
|
||||
#[cfg(test)]
|
||||
mod tests_typescale;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_variables;
|
||||
|
||||
@@ -330,6 +330,426 @@ fn selection_tool_drag_is_one_undo_step() {
|
||||
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0);
|
||||
}
|
||||
|
||||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||
fn history(s: &Session) -> (usize, usize, bool) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
}
|
||||
|
||||
/// Select `ids` with a move of them left to redo, and mark the document saved.
|
||||
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
/// An Align that moves nothing (one object on the artboard's left edge, a pair already aligned, a
|
||||
/// pair that an earlier Align left a rounding error apart) records no undo step: the step to redo
|
||||
/// stays, the document stays unmodified, and the journal, which the Actions panel records from,
|
||||
/// still has the command.
|
||||
#[test]
|
||||
fn an_align_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||||
let left = json!({"horizontal": "left"});
|
||||
let a = rect(&mut s, 0.0, 50.0, 40.0, 40.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||||
// Object › Align sends no `to`, and the Align panel aligns one object to the artboard.
|
||||
for p in [left.clone(), json!({"horizontal": "left", "to": "artboard"})] {
|
||||
s.execute("object.align", &p).unwrap();
|
||||
assert_eq!(history(&s), before, "one object and no key: {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &("object.align".to_string(), p));
|
||||
}
|
||||
let b = rect(&mut s, 0.0, 200.0, 80.0, 20.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
let before = history(&s);
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert_eq!(history(&s), before, "a pair already aligned");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((x0(&s, a), x0(&s, b)), (5.0, 5.0), "Redo brings back the move");
|
||||
// An Align that moves something is one undo step, and it clears Redo.
|
||||
let c = rect(&mut s, 120.0, 300.0, 20.0, 20.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a, c]);
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert_eq!(history(&s), (undo + 1, 0, true));
|
||||
assert_eq!(x0(&s, c), 5.0);
|
||||
// Aligning a rectangle at 51.8 to one at 10.7 leaves it a rounding error off 10.7, and a
|
||||
// second Align records no step.
|
||||
let d = rect(&mut s, 10.7, 400.0, 20.0, 20.0);
|
||||
let e = rect(&mut s, 51.8, 450.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [d.0, e.0]})).unwrap();
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert!(x0(&s, e) != 10.7 && (x0(&s, e) - 10.7).abs() < 1e-12, "{}", x0(&s, e));
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = history(&s);
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert_eq!(history(&s), before, "a pair the first Align aligned");
|
||||
}
|
||||
|
||||
/// Distribute and Distribute Spacing over objects already evenly spaced move nothing and record no
|
||||
/// undo step either.
|
||||
#[test]
|
||||
fn a_distribute_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
// The rectangles are 20 pt wide and 30 pt apart.
|
||||
let ids = [0.0, 50.0, 100.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
|
||||
select_with_a_step_to_redo(&mut s, &ids);
|
||||
let before = history(&s);
|
||||
let calls = [
|
||||
("object.distribute", json!({"horizontal": "left"})),
|
||||
("object.distribute", json!({"horizontal": "center"})),
|
||||
("object.distribute", json!({"horizontal": "right"})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal"})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 30})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
s.execute(id, &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{id} {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
|
||||
}
|
||||
// Distribute Spacing keeps the key object still and spaces the others from it.
|
||||
s.execute("select.key", &json!({"id": ids[1].0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(ids[1]));
|
||||
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 30})).unwrap();
|
||||
assert_eq!(history(&s), before, "spaced 30 pt from the key object");
|
||||
// After the middle one moves 10 pt right, Distribute moves it back in one undo step.
|
||||
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1);
|
||||
assert_eq!(s.doc().unwrap().doc.node(ids[1]).unwrap().geometric_bounds().unwrap().x0, 50.0);
|
||||
// For rectangles 30.1 pt apart, the gap Distribute Spacing computes moves the third one by a
|
||||
// rounding error.
|
||||
let ids = [0.1, 50.2, 100.3].map(|x| rect(&mut s, x, 100.0, 20.0, 20.0));
|
||||
select_with_a_step_to_redo(&mut s, &ids);
|
||||
let before = history(&s);
|
||||
s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert_eq!(history(&s), before, "30.1 pt apart");
|
||||
// Distributing these three leaves the middle one a rounding error off halfway between the
|
||||
// others, and a second Distribute records no step.
|
||||
let ids = [40.045, 433.482, 1935.587].map(|x| rect(&mut s, x, 200.0, 20.0, 20.0));
|
||||
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
|
||||
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = history(&s);
|
||||
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
|
||||
assert_eq!(history(&s), before, "three objects the first Distribute spaced");
|
||||
}
|
||||
|
||||
/// Move by 0 pt, Rotate and Shear by 0°, Scale to 100%, an identity Transform, Set Bounds with the
|
||||
/// current position and size, a Nudge of 0 (of objects, anchors or ruler guides), a Transform with
|
||||
/// no object to transform and a Selection tool drag that ends where it started record no undo
|
||||
/// step. Transform Again then repeats the identity, and a Nudge of 0 of a live rectangle's anchor
|
||||
/// leaves the rectangle a live shape.
|
||||
#[test]
|
||||
fn a_transform_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
// The Nudges of a guide and of anchors below use this ruler guide and this plain path.
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap();
|
||||
let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 300}, {"x": 400, "y": 300}]})).unwrap();
|
||||
let path = NodeId(made["id"].as_u64().unwrap());
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
let calls = [
|
||||
("object.move", json!({"dx": 0, "dy": 0})),
|
||||
("object.rotate", json!({"angle": 0})),
|
||||
("object.scale", json!({"sx": 100})),
|
||||
("object.shear", json!({"angle": 0})),
|
||||
("object.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})),
|
||||
("object.nudge", json!({"dx": 0, "dy": 0})),
|
||||
("object.setBounds", json!({})),
|
||||
("object.transformAgain", json!({})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}");
|
||||
assert_eq!(history(&s), before, "{id} {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().last_transform, Some((Affine::IDENTITY, false)));
|
||||
// A Nudge of 0 with an anchor of the rectangle direct-selected keeps it a live shape.
|
||||
let is_live = |s: &Session| matches!(s.doc().unwrap().doc.node(a).unwrap().kind, NodeKind::Path { live: Some(_), .. });
|
||||
assert!(is_live(&s), "a rectangle is a live shape");
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("object.nudge", &json!({"dx": 0, "dy": 0})).unwrap();
|
||||
assert!(is_live(&s), "a Nudge of 0 keeps it a live shape");
|
||||
assert_eq!(history(&s), before, "an anchor of the live rectangle, then object.nudge");
|
||||
// A Nudge of 0 with anchors direct-selected or with only a ruler guide selected, and a
|
||||
// Transform with nothing selected or with ids that name no object, with or without a copy,
|
||||
// record no undo step either.
|
||||
let calls = [
|
||||
("select.anchors", json!({"id": path.0, "anchors": [[0, 0]]}), "object.nudge", json!({"dx": 0, "dy": 0})),
|
||||
("guide.select", json!({"indexes": [0]}), "object.nudge", json!({"dx": 0, "dy": 0})),
|
||||
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})),
|
||||
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})),
|
||||
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999]})),
|
||||
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true})),
|
||||
];
|
||||
for (select, sp, id, p) in calls {
|
||||
s.execute(select, &sp).unwrap();
|
||||
s.execute(id, &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{select} {sp}, then {id} {p}");
|
||||
}
|
||||
// A copy of no object lists no copies and keeps the selection.
|
||||
let p = json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true});
|
||||
assert_eq!(s.execute("object.transform", &p).unwrap(), json!({"ids": []}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a]);
|
||||
// A Selection tool drag that ends where it started records no undo step either. Back at the
|
||||
// start, the revision advances, so views redraw the object where it was.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let x0 = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0;
|
||||
let revision = |s: &Session| s.doc().unwrap().revision;
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 35.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 35.0), v).unwrap();
|
||||
assert_ne!(x0(&s), 10.0, "the preview moves it");
|
||||
let moved = revision(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 30.0, 35.0), v).unwrap();
|
||||
assert_eq!(x0(&s), 10.0);
|
||||
assert!(revision(&s) > moved, "views redraw it back at the start");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 30.0, 35.0), v).unwrap();
|
||||
assert_eq!(history(&s), before, "a drag back to the start");
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.transform");
|
||||
// With a copy, a Move by 0 pt duplicates the object in one undo step.
|
||||
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
|
||||
let n = count(&s);
|
||||
s.execute("object.move", &json!({"dx": 0, "dy": 0, "copy": true})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
assert_eq!(count(&s), n + 1);
|
||||
}
|
||||
|
||||
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
|
||||
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
|
||||
/// Current Layer on objects already on top of the current layer.
|
||||
#[test]
|
||||
fn an_arrange_that_changes_no_order_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let ids = [0.0, 30.0, 60.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
|
||||
let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
|
||||
let calls = [
|
||||
(vec![ids[2]], "object.arrange.bringToFront"),
|
||||
(vec![ids[2]], "object.arrange.bringForward"),
|
||||
(vec![ids[0]], "object.arrange.sendToBack"),
|
||||
(vec![ids[0]], "object.arrange.sendBackward"),
|
||||
(vec![ids[2]], "object.arrange.sendToCurrentLayer"),
|
||||
(vec![ids[1], ids[2]], "object.arrange.bringToFront"),
|
||||
(vec![ids[1], ids[2]], "object.arrange.bringForward"),
|
||||
(vec![ids[0], ids[1]], "object.arrange.sendToBack"),
|
||||
(vec![ids[0], ids[1]], "object.arrange.sendBackward"),
|
||||
(vec![ids[1], ids[2]], "object.arrange.sendToCurrentLayer"),
|
||||
];
|
||||
for (sel, c) in calls {
|
||||
select_with_a_step_to_redo(&mut s, &sel);
|
||||
let before = history(&s);
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), before, "{c} {sel:?}");
|
||||
assert_eq!(order(&s), ids, "{c} {sel:?}");
|
||||
}
|
||||
// Bring to Front on the back object is one undo step, and it clears Redo.
|
||||
select_with_a_step_to_redo(&mut s, &[ids[0]]);
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
|
||||
assert_eq!(history(&s), (undo + 1, 0, true));
|
||||
assert_eq!(order(&s), [ids[1], ids[2], ids[0]]);
|
||||
}
|
||||
|
||||
/// Ungroup, Release Compound Path and Release Clipping Mask with none of their objects selected,
|
||||
/// and Expand Shape with no live shape selected, record no undo step and keep the selection and its
|
||||
/// key object.
|
||||
#[test]
|
||||
fn ungroup_release_and_expand_shape_with_nothing_to_act_on_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
// Expand Shape makes the two rectangles plain paths in one undo step.
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.expandShape", &json!({})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1);
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
s.execute("select.key", &json!({"id": a.0})).unwrap();
|
||||
let (before, selection) = (history(&s), s.doc().unwrap().selection.clone());
|
||||
assert_eq!(selection.key, Some(a));
|
||||
for c in ["object.ungroup", "object.compoundPath.release", "object.clippingMask.release", "object.expandShape"] {
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert_eq!(s.doc().unwrap().selection, selection, "{c}");
|
||||
assert_eq!(s.journal.last().unwrap().0, c);
|
||||
}
|
||||
// Ungroup with a group selected is one undo step.
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.ungroup", &json!({})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1);
|
||||
}
|
||||
|
||||
/// Unlock All with nothing locked and Show All with nothing hidden record no undo step and keep the
|
||||
/// selection. Lock with every selected object already locked and Hide with every one already
|
||||
/// hidden record no undo step and deselect them.
|
||||
#[test]
|
||||
fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
for c in ["object.unlockAll", "object.showAll"] {
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}");
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a], "{c}");
|
||||
}
|
||||
// With the object locked or hidden, each is one undo step that selects it.
|
||||
for (off, on) in [("object.lock", "object.unlockAll"), ("object.hide", "object.showAll")] {
|
||||
s.execute(off, &json!({})).unwrap();
|
||||
let undo = history(&s).0;
|
||||
assert_eq!(s.execute(on, &json!({})).unwrap(), json!({"count": 1}), "{on}");
|
||||
assert_eq!(history(&s).0, undo + 1, "{on}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a], "{on}");
|
||||
}
|
||||
// Object Properties locks (then hides) a new rectangle `b`, and `select.set` selects it.
|
||||
// Lock (then Hide) deselects it and records no undo step. With `a` selected too, it is one
|
||||
// undo step.
|
||||
for (c, props) in [("object.lock", json!({"locked": true})), ("object.hide", json!({"visible": false}))] {
|
||||
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
|
||||
s.execute("object.setProps", &props).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
let before = history(&s);
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty(), "{c}");
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true), "{c} with a");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set Bounds with the current position and size records no undo step on a rotated object, with
|
||||
/// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these
|
||||
/// cases computing the transform can leave a rounding error in it.
|
||||
#[test]
|
||||
fn set_bounds_with_the_current_position_and_size_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
let c = rect(&mut s, 0.1, 0.05, 10.1, 6.06);
|
||||
s.execute("stroke.set", &json!({"weight": 0.3})).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
for (id, preview) in [(a, false), (a, true), (c, true)] {
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": preview})).unwrap();
|
||||
// The Transform panel's values: X and Y place the reference point, W and H are the sides.
|
||||
let b = s.transform_box(&[id]).unwrap();
|
||||
let (rp, w, h) = (b.reference_point(4), b.rect.width(), b.rect.height());
|
||||
for p in [json!({}), json!({"x": rp.x, "y": rp.y, "width": w, "height": h}), json!({"width": w, "proportional": true})] {
|
||||
assert_eq!(s.execute("object.setBounds", &p).unwrap(), json!({"ids": [id.0]}), "{p}");
|
||||
assert_eq!(history(&s), before, "{id:?}, Use Preview Bounds {preview}: {p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.setBounds");
|
||||
}
|
||||
}
|
||||
// A new width is one undo step, and it clears Redo.
|
||||
s.execute("object.setBounds", &json!({"width": 50})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
/// Object Properties with the values the objects already have records no undo step: every
|
||||
/// property, data already set or already absent, and no object. A value that differs on one of
|
||||
/// the objects is one undo step, and an id that names no object is still an error.
|
||||
#[test]
|
||||
fn object_properties_already_set_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
|
||||
let set = json!({"ids": [a.0], "name": "A", "opacity": 50, "blend": "Multiply", "knockout": "on", "data": {"pivot": "1,2"}});
|
||||
s.execute("object.setProps", &set).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
let before = history(&s);
|
||||
let defaults = json!({
|
||||
"id": b.0, "name": "", "visible": true, "locked": false, "opacity": 100, "blend": "Normal", "isolate": false,
|
||||
"knockout": "neutral", "knockoutShape": false, "data": {"pivot": null}
|
||||
});
|
||||
let calls =
|
||||
[set, defaults, json!({"id": a.0, "data": {"data-pivot": "1,2", "other": null}}), json!({"visible": true}), json!({}), json!({"ids": []})];
|
||||
for p in calls {
|
||||
s.execute("object.setProps", &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &("object.setProps".to_string(), p));
|
||||
}
|
||||
// `a` already has 50% opacity, and `b` does not.
|
||||
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err());
|
||||
}
|
||||
|
||||
/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle,
|
||||
/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the
|
||||
/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that
|
||||
/// differs is one undo step. Setting the radius of a rectangle that kept a scale in its transform
|
||||
/// is one undo step too, because it folds the scale into the rectangle's size.
|
||||
#[test]
|
||||
fn live_shape_properties_already_set_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 60.0);
|
||||
let polygon = id(s.execute("shape.polygon", &json!({"cx": 300, "cy": 100, "radius": 50, "sides": 6})).unwrap());
|
||||
let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap());
|
||||
let star = id(s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 60, "radius2": 30})).unwrap());
|
||||
// The rectangle's corners get 8 pt radii, its top-right corner gets 12 pt, and the polygon gets
|
||||
// 30 pt sides.
|
||||
for p in [json!({"id": r.0, "radius": 8}), json!({"id": r.0, "corners": [1], "radius": 12}), json!({"id": polygon.0, "sideLength": 30})] {
|
||||
s.execute("object.setLiveShape", &p).unwrap();
|
||||
}
|
||||
let NodeKind::Path { live: Some(l), .. } = &s.doc().unwrap().doc.node(polygon).unwrap().kind else { panic!("not a live polygon") };
|
||||
let (angle, radius) = (l.polygon_angle().unwrap(), l.polygon_radius().unwrap());
|
||||
select_with_a_step_to_redo(&mut s, &[r, polygon, ellipse, star]);
|
||||
let before = history(&s);
|
||||
let calls = [
|
||||
json!({"id": polygon.0, "sides": 6, "polygonAngle": angle, "polygonRadius": radius, "makeSidesEqual": true}),
|
||||
json!({"id": polygon.0, "sideLength": 30, "kind": "round"}),
|
||||
json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 360}),
|
||||
json!({"id": ellipse.0, "invertPie": true}),
|
||||
json!({"id": r.0, "corners": [0, 2, 3], "radius": 8, "kind": "round"}),
|
||||
json!({"items": [{"id": r.0, "corners": [1]}], "radius": 12}),
|
||||
json!({"id": star.0, "radius": 0}),
|
||||
];
|
||||
for p in calls {
|
||||
s.execute("object.setLiveShape", &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &("object.setLiveShape".to_string(), p));
|
||||
}
|
||||
// With an anchor of the top-left corner direct-selected, the radius applies to that corner.
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("object.setLiveShape", &json!({"radius": 8})).unwrap();
|
||||
assert_eq!(history(&s), before, "the Direct-Selected corner");
|
||||
for p in [json!({"id": polygon.0, "sides": 7}), json!({"id": ellipse.0, "pieEnd": 90}), json!({"radius": 9})] {
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.setLiveShape", &p).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1, "{p}");
|
||||
}
|
||||
// The rectangle is now 50 × 60 pt with 10 pt corners, stretched to 100 × 60 pt by its
|
||||
// transform, as files from before #291 saved it. Setting the radius its corners show makes the
|
||||
// corners circles.
|
||||
{
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") };
|
||||
let xf = Affine::scale_non_uniform(2.0, 1.0);
|
||||
*live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf };
|
||||
*path = live.to_path();
|
||||
}
|
||||
let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0);
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.setLiveShape", &json!({"id": r.0, "radius": shown})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1, "a stretched rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_tool_draws() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -563,3 +563,35 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() {
|
||||
legacy["para"]["justify"] = json!("Left");
|
||||
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
|
||||
}
|
||||
|
||||
/// #1063: a fill (or stroke) chosen while the Type tool has characters selected colours just
|
||||
/// those characters, as one undo step; the rest of the type keeps its colour.
|
||||
#[test]
|
||||
fn a_fill_chosen_with_characters_selected_colours_only_them() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 50.0, 300.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("Sample Text", v).unwrap();
|
||||
let before = obj(&s, id).runs[0].style.fill.clone();
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 6}));
|
||||
s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(t.runs.len(), 2);
|
||||
assert_eq!(t.runs[0].text, "Sample");
|
||||
assert_eq!(t.runs[0].style.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 160, 0)));
|
||||
assert_eq!(t.runs[1].text, " Text");
|
||||
assert_eq!(t.runs[1].style.fill, before);
|
||||
// A stroke goes on the selected characters only, with a weight to show.
|
||||
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert!(t.runs[0].style.stroke_width > 0.0 && !t.runs[0].style.stroke.is_none());
|
||||
assert!(t.runs[1].style.stroke.is_none());
|
||||
// One undo step each.
|
||||
let labels = undo_labels(&s);
|
||||
assert_eq!(&labels[labels.len() - 2..], ["Fill Color", "Stroke Color"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before));
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! #1034: Character sizes and horizontal scales account for the object's affine transform.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, size: f64) -> NodeId {
|
||||
NodeId(s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Axis label", "size": size})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn em(t: &TextObject, run: usize) -> (f64, f64) {
|
||||
let st = &t.runs[run].style;
|
||||
let origin = t.xf * Point::ZERO;
|
||||
((t.xf * Point::new(st.size * st.h_scale / 100.0, 0.0) - origin).hypot(), (t.xf * Point::new(0.0, st.size * st.v_scale / 100.0) - origin).hypot())
|
||||
}
|
||||
|
||||
fn near(actual: f64, expected: f64) {
|
||||
assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_font_size_setter_sets_document_points_and_undoes() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 150})).unwrap();
|
||||
let before = text(&s, id);
|
||||
near(em(&before, 0).1, 36.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf, "font editing preserves placement and rotation");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_font_size_setter_handles_different_accumulated_scales() {
|
||||
let mut s = session();
|
||||
let a = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
let b = create(&mut s, 18.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
near(em(&text(&s, a), 0).1, 48.0);
|
||||
near(em(&text(&s, b), 0).1, 36.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
near(em(&text(&s, a), 0).1, 10.0);
|
||||
near(em(&text(&s, b), 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_range_size_and_keyboard_steps_use_document_points() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "size": 10})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 10.0);
|
||||
near(em(&text(&s, id), 1).1, 24.0);
|
||||
s.prefs.type_size_increment = 2.0;
|
||||
s.execute("type.step", &json!({"attribute": "size", "id": id.0, "start": 0, "end": 4})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 12.0);
|
||||
near(em(&text(&s, id), 1).1, 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_scale_reset_removes_object_stretch_including_groups() {
|
||||
for grouped in [false, true] {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
if grouped {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setFormat", &json!({"hScale": 100, "vScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 12.0);
|
||||
near(em(&after, 0).1, 12.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_reflected_nonuniform_range_edits_preserve_the_affine() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
// A quarter turn, reflected in the baseline axis: horizontal ×2, vertical ×3.
|
||||
s.execute("object.transform", &json!({"matrix": [0, -2, -3, 0, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100, "vScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 10.0);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
// The native document representation retains both the compensation and the affine.
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
||||
let NodeKind::Text(t) = &reopened.node(id).unwrap().kind else { panic!("not text") };
|
||||
near(em(t, 0).0, 10.0);
|
||||
near(em(t, 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_range_query_leaves_raw_runs_available_for_clipboard() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
|
||||
let query = |s: &mut Session, effective| -> Value {
|
||||
s.execute("text.getRange", &json!({"id": id.0, "effective": effective})).unwrap()["runs"][0]["style"].clone()
|
||||
};
|
||||
let effective = query(&mut s, true);
|
||||
assert_eq!(effective["size"], 24.0);
|
||||
assert_eq!(effective["h_scale"], 150.0);
|
||||
let raw = query(&mut s, false);
|
||||
assert_eq!(raw["size"], 12.0);
|
||||
assert_eq!(raw["h_scale"], 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_point_units_and_small_sizes_are_converted_before_local_storage() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"size": 0.1, "leading": 14})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(em(&t, 0).1, 0.1);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 14.0);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 12, "baselineShift": 4, "leading": 16})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(t.runs[0].style.baseline_shift * 2.0, 4.0);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singular_text_size_edit_returns_an_error_and_rolls_back_the_group() {
|
||||
let mut s = session();
|
||||
let a = create(&mut s, 12.0);
|
||||
let b = create(&mut s, 18.0);
|
||||
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("text.setStyle", &json!({"size": 10})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": b.0, "size": 10})).is_err());
|
||||
assert!(s.execute("text.setFormat", &json!({"id": b.0, "hScale": 100})).is_err());
|
||||
// Non-dimensional edits remain possible, including on imported collapsed text.
|
||||
s.execute("text.setStyle", &json!({"fill": "#123456"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheared_text_keeps_its_affine_and_accepts_exact_em_dimensions() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 1, 2, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 10.0);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_frame_resize_reflows_at_same_size_and_object_scaling_reports_scaled_size() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Area label", "size": 12, "area": {"width": 120, "height": 80}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0], "typeAreas": true})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 24.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_based_keyboard_increments_and_format_fields_follow_document_units() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"leading": 14})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"baselineShift": 4})).unwrap();
|
||||
s.prefs.type_size_increment = 2.0;
|
||||
s.prefs.baseline_shift_increment = 3.0;
|
||||
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
|
||||
s.execute("type.step", &json!({"attribute": "baselineShift"})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
|
||||
near(t.runs[0].style.baseline_shift * 2.0, 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [1e-306, 0, 0, 1e-306, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let error = s.execute("text.setStyle", &json!({"size": 160})).unwrap_err();
|
||||
assert!(matches!(error, EngineError::BadParams { .. }), "reject before attempting unsafe layout: {error}");
|
||||
assert_eq!(text(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
|
||||
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
|
||||
//! values its equivalent), `Xx`/`XX` a named colour of either model
|
||||
//! (`c m y k [r g b] (name) tint type`), `Xk`/`XK` a global process colour (its operands as
|
||||
//! `Xx`'s: a global swatch that isn't an ink of its own). Lower case paints fills, upper case
|
||||
//! strokes.
|
||||
//! values its equivalent), `Xx`/`XX` a named colour of any model
|
||||
//! (`c m y k [r g b | L a b] (name) tint type`, type 0 CMYK, 1 RGB, 2 Lab), `Xk`/`XK` a global
|
||||
//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own). Lower
|
||||
//! case paints fills, upper case strokes.
|
||||
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
|
||||
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
|
||||
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
|
||||
@@ -57,8 +57,8 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces
|
||||
/// the model.
|
||||
/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab);
|
||||
/// `rgb` forces RGB or CMYK.
|
||||
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
|
||||
let name = vals.get(at)?.text()?;
|
||||
@@ -67,9 +67,14 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
let unit = |v: f32| v.clamp(0.0, 1.0);
|
||||
let tint = 1.0 - unit(*after.first()?);
|
||||
let n = before.len();
|
||||
let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) {
|
||||
let kind = after.get(1).copied();
|
||||
let color = if rgb.unwrap_or(kind == Some(1.0)) {
|
||||
let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None };
|
||||
Color::rgb(unit(*r), unit(*g), unit(*b))
|
||||
} else if rgb.is_none() && kind == Some(2.0) {
|
||||
// Type 2: a colour defined in Lab, its L a b values after the CMYK equivalent (#1032).
|
||||
let [l, a, b] = before.get(n.checked_sub(3)?..)? else { return None };
|
||||
Color::lab(l.clamp(0.0, 100.0), a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0))
|
||||
} else {
|
||||
// The CMYK values come first (an RGB equivalent may follow them).
|
||||
let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? };
|
||||
@@ -266,6 +271,16 @@ mod tests {
|
||||
v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]);
|
||||
let (p, _) = color_op("Xk", &v).unwrap();
|
||||
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0)));
|
||||
// A spot colour defined in Lab (type 2): its L a b values, not its CMYK equivalent (#1032).
|
||||
let mut v = nums(&[0.52, 0.98, 0.28, 0.1, 32.9412, 45.0, -18.0]);
|
||||
v.extend([V::Str(b"Plum Ink".to_vec()), V::Num(0.0), V::Num(2.0)]);
|
||||
let (p, named) = color_op("Xx", &v).unwrap();
|
||||
let named = named.unwrap();
|
||||
assert_eq!((named.color, named.spot, named.tint), (Color::lab(32.9412, 45.0, -18.0), true, 1.0));
|
||||
assert_eq!(p.color(), Some(Color::lab(32.9412, 45.0, -18.0)));
|
||||
v[8] = V::Num(0.25);
|
||||
let (_, named) = color_op("XX", &v).unwrap();
|
||||
assert_eq!(named.map(|n| (n.color, n.tint)), Some((Color::lab(32.9412, 45.0, -18.0), 0.75)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -389,12 +389,16 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
|
||||
Object::Array(a) => {
|
||||
let mut it = a.flex_iter();
|
||||
match it.next::<Name<'_>>()?.as_str() {
|
||||
"ICCBased" => match it.next::<Stream<'_>>()?.dict().get::<u8>(b"N")? {
|
||||
1 => Some(Alt::Gray),
|
||||
3 => Some(Alt::Rgb),
|
||||
4 => Some(Alt::Cmyk),
|
||||
_ => None,
|
||||
},
|
||||
"ICCBased" => {
|
||||
let profile = it.next::<Stream<'_>>()?;
|
||||
match profile.dict().get::<u8>(b"N")? {
|
||||
1 => Some(Alt::Gray),
|
||||
3 if lab_profile(&profile) => Some(Alt::Lab),
|
||||
3 => Some(Alt::Rgb),
|
||||
4 => Some(Alt::Cmyk),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"Lab" => Some(Alt::Lab),
|
||||
"CalRGB" => Some(Alt::Rgb),
|
||||
"CalGray" => Some(Alt::Gray),
|
||||
@@ -406,6 +410,27 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the ICC profile of an `ICCBased` space a Lab one (its alternate space `Lab`, else its header's
|
||||
/// data colour space `Lab `)? Spot colours defined in Lab come with such a profile as their
|
||||
/// alternate space (#1032), and their tint transforms give Lab values.
|
||||
fn lab_profile(profile: &Stream<'_>) -> bool {
|
||||
use std::io::Read;
|
||||
if let Some(Object::Array(a)) = profile.dict().get::<Object<'_>>(b"Alternate") {
|
||||
return a.flex_iter().next::<Name<'_>>().is_some_and(|n| n.as_str() == "Lab");
|
||||
}
|
||||
// The header's first 20 bytes, without decoding the rest of the profile.
|
||||
let raw = profile.raw_data();
|
||||
let mut head = vec![];
|
||||
match profile.filters().as_slice() {
|
||||
[] => head.extend(raw.iter().take(20)),
|
||||
[hayro_syntax::Filter::FlateDecode] => {
|
||||
let _ = flate2::read::ZlibDecoder::new(&*raw).take(20).read_to_end(&mut head); // a damaged profile reads as not Lab
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
head.get(16..20) == Some(b"Lab ")
|
||||
}
|
||||
|
||||
/// An alternate-space colour (the tint transform's output) as a VectorCraft colour.
|
||||
fn alt_color(alt: Alt, v: &[f32]) -> Option<Color> {
|
||||
let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite());
|
||||
|
||||
@@ -215,3 +215,33 @@ fn colour_spaces_are_read_from_the_interpreters_debug_form() {
|
||||
assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4]));
|
||||
assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None);
|
||||
}
|
||||
|
||||
/// Spot colours defined in Lab can have an ICC Lab profile as their alternate space, `[/ICCBased
|
||||
/// profile]` with 3 components: the tint transform gives Lab values, which are not RGB (#1032). The
|
||||
/// profile is told by its `Alternate` (`[/Lab …]`), else by its header's data colour space.
|
||||
#[test]
|
||||
fn spot_colours_with_an_icc_lab_alternate_keep_their_lab_values() {
|
||||
let f = first_extra(1);
|
||||
// A profile's first 20 bytes: size, CMM, version, class, then the data colour space.
|
||||
let profile = |head: &str, alternate: &str| format!("<< /N 3 {alternate} /Length {} >>\nstream\n{head}\nendstream", head.len());
|
||||
let lab_header = profile("0000none0000spacLab XYZ ", "");
|
||||
let lab_alternate = profile("0000none0000spacXYZ XYZ ", "/Alternate [/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-128 127 -128 127] >>]");
|
||||
let rgb = profile("0000none0000mntrRGB XYZ ", "");
|
||||
let tint = |c1: &str| format!("<< /FunctionType 2 /Domain [0 1] /C0 [100 0 0] /C1 [{c1}] /N 1 >>");
|
||||
let spaces = format!(
|
||||
"/CS0 [/Separation /Plum [/ICCBased {f} 0 R] {}] /CS1 [/Separation /Sky [/ICCBased {} 0 R] {}] /CS2 [/Separation /Leafy [/ICCBased {} 0 R] << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0.5 0.9 0.1] /N 1 >>]",
|
||||
tint("32.9412 45 -18"),
|
||||
f + 1,
|
||||
tint("51 -26 -45"),
|
||||
f + 2
|
||||
);
|
||||
let page = page_with(&spaces, &[("CS0", "1"), ("CS1", "0.5"), ("CS2", "1")]);
|
||||
let d = import(&pdf_with(&[page], &[&lab_header, &lab_alternate, &rgb], None)).unwrap();
|
||||
let f = fills(&d);
|
||||
assert_eq!(solid(&f[0]).1, Some("Plum"));
|
||||
assert!(near(d.swatch("Plum").unwrap().paint.color().unwrap(), Color::lab(32.941, 45.0, -18.0)), "{:?}", d.swatch("Plum"));
|
||||
assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Sky"), 0.5));
|
||||
assert!(near(d.swatch("Sky").unwrap().paint.color().unwrap(), Color::lab(51.0, -26.0, -45.0)), "{:?}", d.swatch("Sky"));
|
||||
// An RGB profile stays RGB.
|
||||
assert!(near(d.swatch("Leafy").unwrap().paint.color().unwrap(), Color::rgb(0.5, 0.9, 0.1)), "{:?}", d.swatch("Leafy"));
|
||||
}
|
||||
+13
-9
@@ -109,15 +109,19 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
// Manual kerning replaces the font's pair kerning.
|
||||
p.push(("font-kerning", "none".into()));
|
||||
}
|
||||
if !st.features.is_empty() {
|
||||
let v: Vec<String> = st
|
||||
.features
|
||||
.iter()
|
||||
.map(|t| match t.strip_prefix('-') {
|
||||
Some(off) => format!("\"{off}\" 0"),
|
||||
None => format!("\"{t}\" 1"),
|
||||
})
|
||||
.collect();
|
||||
let mut v: Vec<String> = st
|
||||
.features
|
||||
.iter()
|
||||
.map(|t| match t.strip_prefix('-') {
|
||||
Some(off) => format!("\"{off}\" 0"),
|
||||
None => format!("\"{t}\" 1"),
|
||||
})
|
||||
.collect();
|
||||
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
|
||||
if st.proportional_metrics {
|
||||
v.push("\"palt\" 1".into());
|
||||
}
|
||||
if !v.is_empty() {
|
||||
p.push(("font-feature-settings", v.join(", ")));
|
||||
}
|
||||
match (st.underline, st.strikethrough) {
|
||||
|
||||
@@ -105,6 +105,20 @@ fn rich_text_writes_spacing_shift_and_scale() {
|
||||
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
|
||||
#[test]
|
||||
fn proportional_metrics_write_palt() {
|
||||
let proportional = CharStyle { proportional_metrics: true, ..style(12.0) };
|
||||
let both = CharStyle { proportional_metrics: true, features: vec!["dlig".into()], ..style(12.0) };
|
||||
let d = area_doc(vec![run("雅楽 ", style(12.0)), run("「あ」 ", proportional), run("ab", both)], 280.0, Justify::Left);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let tags = tspans(&svg);
|
||||
assert_eq!(tags.len(), 3, "{svg}");
|
||||
assert!(!tags[0].contains("palt"), "{}", tags[0]);
|
||||
assert!(tags[1].contains("font-feature-settings:"palt" 1"), "{}", tags[1]);
|
||||
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_tspans_writes_one_per_line() {
|
||||
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };
|
||||
|
||||
@@ -160,6 +160,23 @@ impl OtFeatures {
|
||||
// and digits itself (they would lie upside down, and tate-chu-yoko break).
|
||||
v.push(f(b"vert", true));
|
||||
}
|
||||
if self.proportional(st) {
|
||||
v.push(f(b"palt", true));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
|
||||
/// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before.
|
||||
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
|
||||
st.proportional_metrics && !self.vertical
|
||||
}
|
||||
|
||||
/// The harfrust features for text in style `st` with full-width glyphs on their full widths:
|
||||
/// [`Self::resolve`] without `palt`.
|
||||
pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec<Feature> {
|
||||
let mut v = self.resolve(st);
|
||||
v.retain(|x| x.tag != Tag::new(b"palt"));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,15 +200,21 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
}
|
||||
|
||||
/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which
|
||||
/// it fits, searched by bisection over the first run's size in steps of 0.1 pt (so its scaled size
|
||||
/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size
|
||||
/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there,
|
||||
/// still overflowing. None (keep the full-size layout) when the text has no usable size.
|
||||
fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option<TextLayout> {
|
||||
let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?;
|
||||
// Exact size edits after object scaling may store a local size below 0.1. Fit in the
|
||||
// same document points as the Character controls, independent of that representation.
|
||||
let points = t.style_scale()?.points;
|
||||
let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?;
|
||||
if !(min.is_finite() && min < 1.0) {
|
||||
return None;
|
||||
}
|
||||
let tenths = size * 10.0;
|
||||
if !tenths.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow
|
||||
// unless it is below the full size; `lo` is the smallest allowed.
|
||||
let mut lo = (tenths * min).ceil().max(1.0);
|
||||
@@ -234,7 +240,7 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) ->
|
||||
}
|
||||
// Invariant: everything at `hi` or above overflows; `lo` fits or is the floor.
|
||||
while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES {
|
||||
let mid = ((lo + hi) * 0.5).floor();
|
||||
let mid = (lo + (hi - lo) * 0.5).floor();
|
||||
let l = at(mid);
|
||||
passes += 1;
|
||||
if l.overflow {
|
||||
@@ -381,7 +387,8 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
|
||||
/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its
|
||||
/// advance an em, give or take a tenth), the space that mojikumi can take off.
|
||||
fn punct_half(g: &SGlyph, kind: Punct) -> Option<f64> {
|
||||
if punct(g.ch)? != kind || g.tcy.is_some() {
|
||||
// A glyph Proportional Metrics re-spaced has no empty half left to take off (#966).
|
||||
if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional {
|
||||
return None;
|
||||
}
|
||||
let em = g.face.units_per_em();
|
||||
@@ -1170,6 +1177,7 @@ mod wrapping_tests {
|
||||
tcy: None,
|
||||
inline: None,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
level: unicode_bidi::Level::ltr(),
|
||||
})
|
||||
.collect()
|
||||
|
||||
+44
-24
@@ -49,6 +49,9 @@ pub(crate) struct SGlyph {
|
||||
/// Japanese composition: the space taken off before the glyph (an opening bracket after
|
||||
/// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line.
|
||||
pub lead: f64,
|
||||
/// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width,
|
||||
/// and Japanese composition takes no more space off it (#966).
|
||||
pub proportional: bool,
|
||||
/// Resolved Unicode bidi embedding level (logical source order).
|
||||
pub level: Level,
|
||||
/// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph.
|
||||
@@ -117,6 +120,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
|
||||
h.len = 0;
|
||||
h.ch = '-';
|
||||
h.inline = None;
|
||||
h.proportional = false;
|
||||
h
|
||||
}
|
||||
|
||||
@@ -422,6 +426,7 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
|
||||
ch: text.get(range.clone()).and_then(|s| s.chars().next()).unwrap_or(INLINE_CHAR),
|
||||
tcy: None,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
// U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in
|
||||
// RTL text like any other glyph once the line is reordered.
|
||||
level,
|
||||
@@ -470,34 +475,48 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
|
||||
|
||||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
|
||||
// Proportional Metrics: which glyphs `palt` gave another advance than their full width.
|
||||
let mut proportional = vec![];
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
let shape = |feats: &[Feature]| {
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
}
|
||||
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let feats: Vec<Feature> = feats.resolve(st);
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||||
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(feats));
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
raw = shape(&feats.resolve(st));
|
||||
if feats.proportional(st) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&feats.resolve_fixed_width(st));
|
||||
if full.len() == raw.len() && full.iter().zip(&raw).all(|(a, b)| a.0 == b.0) {
|
||||
proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect();
|
||||
}
|
||||
}
|
||||
});
|
||||
if shaped.is_none() {
|
||||
@@ -553,6 +572,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
ch,
|
||||
tcy: None,
|
||||
lead: 0.0,
|
||||
proportional: proportional.get(gi).copied().unwrap_or(false),
|
||||
level: *level,
|
||||
inline: None,
|
||||
});
|
||||
|
||||
@@ -54,6 +54,19 @@ fn shrink_text_makes_overflowing_text_fit() {
|
||||
assert_eq!(layout(db(), &t).fit_scale, l.fit_scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() {
|
||||
// A 14 pt story represented in a scaled local coordinate system. Its local size is
|
||||
// below 0.1, as happens when setting an exact size after enlarging a text group.
|
||||
let mut t = area(COPY, 0.014, Rect::new(0.0, 0.0, 0.2, 0.09), shrink(30.0));
|
||||
t.xf = Affine::scale(1000.0);
|
||||
assert!(at_size(&t, 0.014).overflow);
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale);
|
||||
let document_size = 14.0 * l.fit_scale;
|
||||
assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_text_respects_its_minimum() {
|
||||
let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0));
|
||||
|
||||
@@ -749,3 +749,48 @@ fn layout_10k_area_text_is_fast() {
|
||||
assert!(per < budget, "{justify:?} {composer:?}: {per} ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional
|
||||
/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt`
|
||||
/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a
|
||||
/// line's end); punctuation it leaves full width is trimmed as before. Vertical type sets as
|
||||
/// before. Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
|
||||
/// 〘〙 (skipped without it).
|
||||
#[test]
|
||||
fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
if db().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
return;
|
||||
}
|
||||
let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() };
|
||||
// Right-aligned in a frame ten ems wide, so a line's end trim moves the line.
|
||||
let lay = |text: &str, on: bool, m: Mojikumi, vertical: bool| {
|
||||
let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right);
|
||||
t.vertical = vertical;
|
||||
t.para.mojikumi = m;
|
||||
layout(db(), &t)
|
||||
};
|
||||
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||||
let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::<Vec<_>>();
|
||||
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
|
||||
let off = adv(&lay("「あ漢」", false, Mojikumi::None, false));
|
||||
let on = adv(&lay("「あ漢」", true, Mojikumi::None, false));
|
||||
assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}");
|
||||
assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}");
|
||||
assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}");
|
||||
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's
|
||||
// start (「), between punctuation (」、) or at the line's end (」).
|
||||
for text in ["「あ」、漢", "漢「あ」"] {
|
||||
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf, false), lay(text, true, Mojikumi::None, false));
|
||||
assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none));
|
||||
assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none));
|
||||
}
|
||||
// Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end.
|
||||
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf, false), lay("〘あ〙", true, Mojikumi::None, false));
|
||||
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
|
||||
let moved = x0(&half)[1] - x0(&none)[1];
|
||||
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
|
||||
// Vertical type sets as before.
|
||||
assert!(near(&adv(&lay("「あ漢」", true, Mojikumi::None, true)), &adv(&lay("「あ漢」", false, Mojikumi::None, true))));
|
||||
}
|
||||
@@ -2000,6 +2000,7 @@
|
||||
Proof Colors Farbproof
|
||||
Proof Setup Proof einrichten
|
||||
Properties Eigenschaften
|
||||
Proportional Metrics Proportionale Metrik
|
||||
Proportional Oldstyle Proportionale Mediävalziffern
|
||||
Pucker & Bloat… Zusammenziehen und aufblasen…
|
||||
Pucker Tool Zusammenziehen-Werkzeug
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2051,6 +2051,7 @@
|
||||
Proof Colors Colores de prueba
|
||||
Proof Setup Ajuste de prueba
|
||||
Properties Propiedades
|
||||
Proportional Metrics Métrica proporcional
|
||||
Proportional Oldstyle Estilo antiguo proporcional
|
||||
Pucker & Bloat… Fruncir y engordar…
|
||||
Pucker Tool Herramienta Fruncir
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2048,6 +2048,7 @@
|
||||
Proof Colors Couleurs d’épreuve
|
||||
Proof Setup Format d’épreuve
|
||||
Properties Propriétés
|
||||
Proportional Metrics Métrique proportionnelle
|
||||
Proportional Oldstyle Elzévirien proportionnel
|
||||
Pucker & Bloat… Contracter et dilater…
|
||||
Pucker Tool Outil Contraction
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2051,6 +2051,7 @@
|
||||
Proof Colors Colori di prova
|
||||
Proof Setup Impostazione prova
|
||||
Properties Proprietà
|
||||
Proportional Metrics Metrica proporzionale
|
||||
Proportional Oldstyle Proporzionali stile antico
|
||||
Pucker & Bloat… Contrai e gonfia…
|
||||
Pucker Tool Strumento Contrai
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -37,6 +37,7 @@
|
||||
Em Box Top/Right 仮想ボディの上 / 右
|
||||
ICF Top/Right 平均字面の上 / 右
|
||||
ICF Bottom/Left 平均字面の下 / 左
|
||||
Proportional Metrics プロポーショナルメトリクス
|
||||
Favorites お気に入り
|
||||
Flip 反転
|
||||
Japanese 日本語
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1721,6 +1721,7 @@
|
||||
Proof Colors Provar Cores
|
||||
Proof Setup Configuração de Prova
|
||||
Properties Propriedades
|
||||
Proportional Metrics Métrica Proporcional
|
||||
Pucker & Bloat… Encolher e Inchar…
|
||||
Pucker Tool Ferramenta Encolher
|
||||
Punctuation Space Espaço de Pontuação
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2047,6 +2047,7 @@
|
||||
Proof Colors Цветопроба
|
||||
Proof Setup Настройка цветопробы
|
||||
Properties Свойства
|
||||
Proportional Metrics Пропорциональные метрики
|
||||
Proportional Oldstyle Пропорциональные старого стиля
|
||||
Pucker & Bloat… Сморщивание и раздувание…
|
||||
Pucker Tool Инструмент «Сморщивание»
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2043,6 +2043,7 @@
|
||||
Proof Colors Пробні кольори
|
||||
Proof Setup Налаштування кольоропроби
|
||||
Properties Властивості
|
||||
Proportional Metrics Пропорційні метрики
|
||||
Proportional Oldstyle Пропорційні цифри старого стилю
|
||||
Pucker & Bloat… Зморщування та роздування …
|
||||
Pucker Tool Інструмент «Зморщування»
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -1976,6 +1976,7 @@
|
||||
Proof Colors 校样颜色
|
||||
Proof Setup 校样设置
|
||||
Properties 属性
|
||||
Proportional Metrics 比例度量
|
||||
Proportional Oldstyle 比例旧式数字
|
||||
Pucker & Bloat… 缩拢与膨胀…
|
||||
Pucker Tool 收缩工具
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2029,6 +2029,7 @@
|
||||
Proof Colors 校樣色彩
|
||||
Proof Setup 校樣設定
|
||||
Properties 內容
|
||||
Proportional Metrics 比例度量
|
||||
Proportional Oldstyle 比例舊式數字
|
||||
Pucker & Bloat… 縮攏與膨脹…
|
||||
Pucker Tool 縮攏工具
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, NodeId, NodeKind};
|
||||
use vectorcraft_tools::{Mods, ToolKey};
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use super::{pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, SpinPick, menu_item};
|
||||
@@ -20,26 +20,62 @@ pub const SIZE_PRESETS: [f64; 15] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0,
|
||||
pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
|
||||
pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0];
|
||||
|
||||
/// The character style shown by the panel: the selected range's (or the caret's) while the Type
|
||||
/// tool edits text, else the first selected text object's first run. With it, the paragraph
|
||||
/// The stored character style: the selected range's (or the caret's) while the Type
|
||||
/// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph
|
||||
/// attributes of the paragraph the selection starts in (else of the first paragraph).
|
||||
/// Font/paragraph controls and named-style capture use this; dimensional fields use `shared_effective`.
|
||||
pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> {
|
||||
if let Some((id, a, b)) = text_editing(app)
|
||||
&& let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind)
|
||||
{
|
||||
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone()));
|
||||
}
|
||||
let n = first_selected(app)?;
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => Some((t.first_style(), t.para_at(0).clone())),
|
||||
_ => None,
|
||||
let t = first_selected_text(app)?;
|
||||
Some((t.first_style(), t.para_at(0).clone()))
|
||||
}
|
||||
|
||||
/// The first selected text descendant, shared by Character and Paragraph controls.
|
||||
pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> {
|
||||
let st = app.session.active()?;
|
||||
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
let mut text = None;
|
||||
node.walk(&mut |n| {
|
||||
if text.is_none()
|
||||
&& let NodeKind::Text(t) = &n.kind
|
||||
{
|
||||
text = Some(t.as_ref());
|
||||
}
|
||||
});
|
||||
if text.is_some() {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the
|
||||
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
|
||||
/// blank field) where they differ, or with no type.
|
||||
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
|
||||
shared_styles(app, |_, style| Some(f(style)))
|
||||
}
|
||||
|
||||
/// Dimensional controls use document units and tolerate arithmetic roundoff when comparing
|
||||
/// compensated runs. Keep raw styles available to font/paragraph controls and style definitions,
|
||||
/// even if a collapsed affine prevents editing dimensions.
|
||||
fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Option<f64> {
|
||||
shared_styles(app, |t, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0)
|
||||
}
|
||||
|
||||
struct Metric(f64);
|
||||
|
||||
impl PartialEq for Metric {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
(self.0 - other.0).abs() <= 1e-9 * self.0.abs().max(other.0.abs()).max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn shared_styles<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject, &CharStyle) -> Option<T>) -> Option<T> {
|
||||
let st = app.session.active()?;
|
||||
let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t),
|
||||
@@ -49,26 +85,26 @@ pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle)
|
||||
if let Some((id, a, b)) = text_editing(app) {
|
||||
let t = text(id)?;
|
||||
if b <= a {
|
||||
return Some(f(&vectorcraft_text::edit::insertion_style(&t.runs, a, b)));
|
||||
return f(t, &vectorcraft_text::edit::insertion_style(&t.runs, a, b));
|
||||
}
|
||||
let mut end = 0;
|
||||
for r in &t.runs {
|
||||
let start = end;
|
||||
end += r.text.len();
|
||||
if start < b && end > a {
|
||||
acc.add(f(&r.style));
|
||||
acc.add(f(t, &r.style));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
n.walk(&mut |c| {
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
t.runs.iter().for_each(|r| acc.add(f(&r.style)));
|
||||
t.runs.iter().for_each(|r| acc.add(f(t, &r.style)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
acc.value()
|
||||
acc.value().flatten()
|
||||
}
|
||||
|
||||
/// The value a run of styles share so far ([`shared`]).
|
||||
@@ -402,7 +438,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
|
||||
egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
|
||||
cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw));
|
||||
cell(ui, "A↕", tl!("Leading"), |ui| {
|
||||
let leading = shared(app, CharStyle::effective_leading);
|
||||
let leading = shared_effective(app, CharStyle::effective_leading);
|
||||
let auto = shared(app, |s| s.leading.is_none()) == Some(true);
|
||||
match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) {
|
||||
Some(SpinPick::Value(v)) => style(app, json!({"leading": v})),
|
||||
@@ -427,18 +463,18 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
|
||||
return;
|
||||
}
|
||||
cell(ui, "IT", tl!("Vertical Scale %"), |ui| {
|
||||
if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
|
||||
if let Some(v) = widgets::spin_plain(ui, "ch-vs", shared(app, |s| s.v_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
|
||||
format(app, json!({"vScale": v}));
|
||||
}
|
||||
});
|
||||
cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| {
|
||||
if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
|
||||
if let Some(v) = widgets::spin_plain(ui, "ch-hs", shared_effective(app, |s| s.h_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
|
||||
format(app, json!({"hScale": v}));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
cell(ui, "Aª", tl!("Baseline Shift"), |ui| {
|
||||
if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
|
||||
if let Some(v) = widgets::spin_field(ui, "ch-bs", shared_effective(app, |s| s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
|
||||
format(app, json!({"baselineShift": v}));
|
||||
}
|
||||
});
|
||||
@@ -454,7 +490,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
|
||||
/// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or
|
||||
/// picked from the presets, in the type unit, blank where the selected text's sizes differ.
|
||||
pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) {
|
||||
let size = shared(app, |s| s.size);
|
||||
let size = shared_effective(app, |s| s.size);
|
||||
if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) {
|
||||
style(app, json!({"size": v}));
|
||||
}
|
||||
@@ -619,6 +655,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
// Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`).
|
||||
let proportional = s.as_ref().is_some_and(|s| s.proportional_metrics);
|
||||
if menu_item(ui, tl!("Proportional Metrics"), has, proportional) {
|
||||
format(app, json!({"proportionalMetrics": !proportional}));
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] {
|
||||
@@ -646,6 +687,81 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone();
|
||||
app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap();
|
||||
let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap();
|
||||
app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap();
|
||||
for (actual, expected) in [
|
||||
(shared_effective(&app, |s| s.size), 7.0),
|
||||
(shared_effective(&app, CharStyle::effective_leading), 9.0),
|
||||
(shared_effective(&app, |s| s.h_scale), 130.0),
|
||||
(shared_effective(&app, |s| s.baseline_shift), 3.0),
|
||||
] {
|
||||
assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_collapsed_text_keeps_nondimensional_controls_available() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap();
|
||||
app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
|
||||
assert!(text_style(&app).is_some());
|
||||
assert_eq!(shared(&app, |s| s.tracking), Some(0.0));
|
||||
assert_eq!(shared_effective(&app, |s| s.size), None);
|
||||
app.run("text.setStyle", json!({"tracking": 40})).unwrap();
|
||||
app.run("text.setFormat", json!({"leftIndent": 4})).unwrap();
|
||||
assert_eq!(shared(&app, |s| s.tracking), Some(40.0));
|
||||
assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0);
|
||||
let ctx = egui::Context::default();
|
||||
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show);
|
||||
assert!(!texts.iter().any(|(s, _)| s == "No text selected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_character_fields_show_document_sizes_and_mixed_group_values() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone();
|
||||
app.run("object.scale", json!({"sx": 200})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.size), Some(24.0));
|
||||
assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes");
|
||||
let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size");
|
||||
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0));
|
||||
app.run("text.setStyle", json!({"size": 10})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.size), Some(10.0));
|
||||
app.run("text.setFormat", json!({"hScale": 100})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_range_fields_follow_the_type_tool_selection() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.session.select_tool("type", Default::default()).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis label", "size": 12})).unwrap()["id"].clone();
|
||||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||||
assert!(text_editing(&app).is_some());
|
||||
app.run("object.scale", json!({"sx": 200})).unwrap();
|
||||
app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap();
|
||||
app.session.set_tool_option("selectAll", &json!(true));
|
||||
assert_eq!(shared_effective(&app, |s| s.size), None);
|
||||
app.session.set_tool_option("select", &json!({"start": 0, "end": 4}));
|
||||
assert_eq!(shared_effective(&app, |s| s.size), Some(10.0));
|
||||
assert_eq!(text_style(&app).unwrap().0.size, 5.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() {
|
||||
@@ -673,17 +789,20 @@ mod tests {
|
||||
assert!(!crate::menus::enabled(&app, "type.bold"));
|
||||
}
|
||||
|
||||
/// Character Alignment is in the panel menu with the East Asian options only (as Mojikumi Set
|
||||
/// and Top-to-Top Leading are in the Paragraph panel).
|
||||
/// Character Alignment and Proportional Metrics are in the panel menu with the East Asian
|
||||
/// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel).
|
||||
#[test]
|
||||
fn character_alignment_shows_with_the_east_asian_options() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone();
|
||||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Alignment");
|
||||
assert!(!shown(&mut app));
|
||||
let shown = |app: &mut VectorcraftApp| {
|
||||
let text = crate::tests_labels::painted_text(app, menu);
|
||||
(text.contains("Character Alignment"), text.contains("Proportional Metrics"))
|
||||
};
|
||||
assert_eq!(shown(&mut app), (false, false));
|
||||
app.session.prefs.show_east_asian_options = true;
|
||||
assert!(shown(&mut app));
|
||||
assert_eq!(shown(&mut app), (true, true));
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,11 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
|
||||
/// right to left: its Paragraph Direction, else from its first strong character?
|
||||
fn resolved_rtl(app: &VectorcraftApp, para: &ParaStyle) -> bool {
|
||||
let editing = super::character::text_editing(app);
|
||||
let selected = super::first_selected(app);
|
||||
let node = editing.and_then(|(id, _, _)| app.session.active().and_then(|d| d.doc.node(id))).or(selected.as_ref());
|
||||
let Some(NodeKind::Text(t)) = node.map(|n| &n.kind) else { return para.direction == Some(ParaDirection::RightToLeft) };
|
||||
let text = editing.and_then(|(id, _, _)| match app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t.as_ref()),
|
||||
_ => None,
|
||||
});
|
||||
let Some(t) = text.or_else(|| super::character::first_selected_text(app)) else { return para.direction == Some(ParaDirection::RightToLeft) };
|
||||
let plain = t.plain_text();
|
||||
let paragraph = vectorcraft_text::edit::paragraph_at(&plain, editing.map_or(0, |(_, a, _)| a));
|
||||
vectorcraft_text::paragraph_is_rtl(plain.get(paragraph).unwrap_or_default(), para.direction)
|
||||
@@ -246,6 +248,8 @@ mod tests {
|
||||
shown_alignment(para.justify, resolved_rtl(app, ¶))
|
||||
};
|
||||
assert_eq!(shown(&app), Justify::Right);
|
||||
app.session.execute("object.group", &json!({})).unwrap();
|
||||
assert_eq!(shown(&app), Justify::Right, "group controls resolve the same text descendant's direction");
|
||||
app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
|
||||
assert_eq!(shown(&app), Justify::Left);
|
||||
app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap();
|
||||
|
||||
@@ -61,6 +61,38 @@ fn a_picked_font_size_preset_applies_in_one_undo_step() {
|
||||
assert_eq!(undo_len(&app), undo + 1, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_font_size_fields_and_preset_use_document_points() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let (mut app, id) = app_with_text();
|
||||
run(&mut app, "object.scale", json!({"sx": 200}));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt"));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt"));
|
||||
let at = Cell::new(egui::Pos2::ZERO);
|
||||
frame_events(&ctx, &mut app, vec![], size_combo(&at));
|
||||
click(&ctx, &mut app, at.get() + egui::vec2(110.0, 13.0), size_combo(&at));
|
||||
let t = frame_events(&ctx, &mut app, vec![], size_combo(&at));
|
||||
let undo = undo_len(&app);
|
||||
click(&ctx, &mut app, text_rect(&t, "10 pt").center(), size_combo(&at));
|
||||
assert_eq!(style(&app, id).size * 2.0, 10.0);
|
||||
assert_eq!(undo_len(&app), undo + 1);
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], size_combo(&at)), "10 pt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_group_shows_character_and_control_bar_size_fields() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let (mut app, _) = app_with_text();
|
||||
run(&mut app, "object.group", json!({}));
|
||||
run(&mut app, "object.scale", json!({"sx": 200}));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt"));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt"));
|
||||
run(&mut app, "text.setStyle", json!({"size": 10}));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "10 pt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_presets_show_in_the_type_unit() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -624,7 +624,8 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
|
||||
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
|
||||
// The undo steps and the journal entries (the dialog's OK runs its command, which the journal records).
|
||||
let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len());
|
||||
app.select_tool("selection");
|
||||
frame(&mut app, vec![enter()]);
|
||||
assert_eq!(kind(&app), None, "nothing is selected");
|
||||
@@ -645,9 +646,11 @@ mod tests {
|
||||
frame(&mut app, vec![]);
|
||||
assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: and it stays open");
|
||||
assert_eq!(steps(&app), before, "{tool}: the Enter that opened it didn't confirm it");
|
||||
// The next Enter is its OK.
|
||||
// The next Enter is its OK. With its default values only Reflect changes the art, so only
|
||||
// Reflect records an undo step.
|
||||
frame(&mut app, vec![enter()]);
|
||||
assert_eq!((kind(&app), steps(&app)), (None, before + 1), "{tool}: Enter confirms");
|
||||
let after = (before.0 + usize::from(dialog == "reflect"), before.1 + 1);
|
||||
assert_eq!((kind(&app), steps(&app)), (None, after), "{tool}: Enter confirms");
|
||||
}
|
||||
app.select_tool("rectangle");
|
||||
frame(&mut app, vec![enter()]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, from the `FORMATS` table and Illustrator 2026's documented formats) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Lab spot colours in `.ai` and PDF, #1032) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public
|
||||
@@ -44,7 +44,7 @@ private editing data. VectorCraft:
|
||||
|
||||
### Illustrator's private editing data
|
||||
|
||||
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
|
||||
## Format by format
|
||||
|
||||
@@ -80,6 +80,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Spot colours defined in Lab keep their Lab values, from the editing data and from a PDF part whose ink alternate is an ICC Lab profile (#1032) |
|
||||
| 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) |
|
||||
| 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) |
|
||||
| 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures |
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (moved from the ROADMAP's "Where we're lacking", re-ranked, added file-format, stability, hardware, localization, ecosystem and AI gaps) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
|
||||
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
|
||||
@@ -86,6 +86,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
## G11 Correctness bugs in core tools
|
||||
|
||||
- **Done:** scaled text's size and horizontal-scale controls account for object/group transforms (#1034),
|
||||
including exact sizes across grouped labels and resetting horizontal stretch while keeping editable text.
|
||||
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885);
|
||||
PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834).
|
||||
- **Impact:** wrong output in everyday work.
|
||||
@@ -96,7 +98,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
- **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical
|
||||
composition (ruby, mojikumi sets and dialog, proportional vertical metrics #966, kinsoku settings #633, manual
|
||||
tate-chu-yoko), Middle Eastern features beyond bidi, variable font axes, spell check with an open dictionary,
|
||||
Touch Type, Retype, Snap to Glyph. Itemized in [type-parity.md](type-parity.md).
|
||||
Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement).
|
||||
Itemized in [type-parity.md](type-parity.md).
|
||||
- **Impact:** professional typography and Japanese/Chinese publishing.
|
||||
- **Estimate:** 34–47 h (type core 14–19 h blocks beta).
|
||||
|
||||
@@ -200,6 +203,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | #1034: corrected scaled type controls; optional group font-size preservation remains separate. Estimates unchanged at this rounding. |
|
||||
| 2026-10-11 | minor | G9: legacy `.ai` without the prolog opens with its layers (#1027) |
|
||||
| 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) |
|
||||
| 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left |
|
||||
|
||||
+14
-1
@@ -284,7 +284,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
|
||||
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
|
||||
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
|
||||
| `undo` / `redo` | `{}` | |
|
||||
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. |
|
||||
|
||||
With `mods.shift`, a marquee dragged with the Selection tool toggles the objects it reaches (the selected ones leave
|
||||
the selection, the others join it, the rest stays), and one dragged with Direct or Group Selection toggles the anchors
|
||||
@@ -2627,6 +2627,19 @@ without ideographs uses its em box. One undo step.
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"charAlign":"icfTop"}}}
|
||||
```
|
||||
|
||||
## Proportional Metrics
|
||||
|
||||
`text.setFormat {proportionalMetrics: true}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end,
|
||||
proportionalMetrics}` (a range) set full-width glyphs on the proportional widths the font gives them with the
|
||||
OpenType `palt` feature; glyphs the font doesn't re-space keep their full width. Line-end Punctuation Half Width then takes nothing more off the punctuation `palt` re-spaced (the opening
|
||||
bracket at a line's start, consecutive punctuation, the closing mark at a line's end); punctuation left full width is
|
||||
trimmed as before. Off by default and saved only when on. Horizontal type only for now: vertical type sets as before.
|
||||
One undo step.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}}
|
||||
```
|
||||
|
||||
## New type in a Japanese interface
|
||||
|
||||
While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em
|
||||
|
||||
+7
-1
@@ -1,7 +1,7 @@
|
||||
# Releasing VectorCraft
|
||||
|
||||
Every push to the `release` branch runs `.github/workflows/release.yml`. The workflow builds
|
||||
installers for macOS, Windows, Linux (including Flatpak bundles) and FreeBSD, plus the web build, and creates or updates a
|
||||
installers for macOS, Windows, Linux (including Flatpak bundles and a RISC-V tarball) and FreeBSD, plus the web build, and creates or updates a
|
||||
**draft** GitHub Release named `VectorCraft v<version>`. Nobody sees a draft until a maintainer
|
||||
publishes it.
|
||||
|
||||
@@ -50,6 +50,7 @@ and no draft release is created.
|
||||
| Windows on ARM64 | `vectorcraft-<v>-windows-arm64.msi`, `vectorcraft-<v>-windows-arm64-portable.zip` | `windows-latest` (cross-compiled) |
|
||||
| Linux x86_64 | `vectorcraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
|
||||
| Linux aarch64 | `vectorcraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
|
||||
| Linux riscv64 | `vectorcraft-<v>-linux-riscv64.tar.gz` (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) | `ubuntu-24.04` |
|
||||
| Linux AppImage updates | `vectorcraft-<v>-linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImages |
|
||||
| Flatpak x86_64, aarch64 | `vectorcraft-<v>-linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) |
|
||||
| FreeBSD 14 x86_64 | `vectorcraft-<v>-freebsd-x86_64.tar.gz` | a FreeBSD 14.3 VM on `ubuntu-latest` |
|
||||
@@ -144,6 +145,11 @@ glibc 2.35 or newer: Ubuntu 22.04+, Debian 12+, Fedora 36+ and RHEL 10. Moving t
|
||||
image raises that floor, so do it deliberately. After packaging, the job prints the `.deb`'s
|
||||
metadata and contents, runs `ldd` on the binary and runs each AppImage with `--version`.
|
||||
|
||||
**RISC-V.** The `linux-riscv64` job cross-compiles `riscv64gc-unknown-linux-gnu` on `ubuntu-24.04`
|
||||
against riscv64 libraries from ports.ubuntu.com (so the floor there is glibc 2.39) and runs
|
||||
`package.sh --formats tar` with `CROSS_ARCH`, `CROSS_TARGET`, `CROSS_COMPILE` and
|
||||
`EMULATOR=qemu-riscv64` set: a `.tar.gz` only, with `vectorcraft-cli --version` run under QEMU.
|
||||
|
||||
**AppImage updates.** Each AppImage embeds the update information
|
||||
`gh-releases-zsync|storytold|vectorcraft|latest|vectorcraft-*-linux-<arch>.AppImage.zsync`
|
||||
(appimagetool `-u`), and `package.sh` writes the matching `.zsync` beside it when `zsyncmake` (the
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (added the Alpha gate table; earlier the same day: moved the milestone table and direction here from ROADMAP.md, added Current focus) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (M7: accurate character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
|
||||
@@ -54,7 +54,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
@@ -79,6 +79,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | M7: document-point character sizes, leading and baseline shift, and resettable horizontal stretching after object/group scaling (#1034). Optional group font-size preservation remains separate; scores/hours unchanged. |
|
||||
| 2026-10-10 | minor | M9 row from main: initial Revolve |
|
||||
| 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha |
|
||||
| 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (#1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
|
||||
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
|
||||
@@ -76,7 +76,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated |
|
||||
| Raster effects (Effect Gallery) | 4 | 48% | 29 Photoshop-style filters (Artistic, Sketch), the Effect Gallery dialog, Load Texture | 24–40 | measured count 28/57 |
|
||||
| 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) |
|
||||
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, see [type-parity.md](type-parity.md) | 14–19 | estimated |
|
||||
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated |
|
||||
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) |
|
||||
| Image Trace, graphs, image tools | 3 | 75% | graph Design/Column/Marker, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) |
|
||||
@@ -305,7 +305,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
|
||||
| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
|
||||
@@ -419,8 +419,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)).
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boLine truncated
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type, and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (vertical type's `vpal` not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
@@ -458,6 +458,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%): 16 core features weighted, depth ~91%, discounted for launch, discoverability and files people send |
|
||||
| 2026-10-10 | minor | Second look at ready for real work at the owner's request: 50% → 55% full Illustrator, and a new mainstream-illustrator figure of 65%. Why: the old 25-point discount was a judgement with no breakdown. Now each discount is written down and checked against the tracker: 86% of 79 core-drawing issues closed, 20 of 28 `.ai`/EPS issues fixed and the open ones about structure, praise from 15 people, no reports of switching; dimensions a typical illustrator rarely hits are taken out of readiness. Feature breadth, depth and hours unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the core-workflow gate: passes, stays alpha |
|
||||
| 2026-10-10 | minor | #1034: accurate transformed character controls; type-core score/hours and headline estimates unchanged at this rounding. |
|
||||
| 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file |
|
||||
| 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones |
|
||||
| 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate |
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, split out of the parity table's two type rows) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -23,6 +23,8 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Threaded text, Fit Headline, Text Wrap | done | |
|
||||
| Type on a Path effects and options; brackets | done | #429 |
|
||||
| Character and Paragraph panels, styles with overrides | done | mixed-value display for differing paragraphs missing |
|
||||
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
|
||||
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
|
||||
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394) |
|
||||
| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 |
|
||||
| Variable fonts | partial | named instances as styles; axis sliders missing |
|
||||
@@ -48,4 +50,5 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | #1034: transformed character controls and regression coverage; preserving group font sizes remains a separate enhancement. Scores/hours unchanged at this rounding. |
|
||||
| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows |
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and package VectorCraft for Linux (<arch> is x86_64 or aarch64):
|
||||
# Build and package VectorCraft for Linux (<arch> is x86_64 or aarch64, or riscv64 when cross-compiling):
|
||||
#
|
||||
# $DIST/vectorcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
|
||||
# $DIST/vectorcraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake)
|
||||
@@ -9,6 +9,10 @@
|
||||
#
|
||||
# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"]
|
||||
#
|
||||
# Cross-compiling (release.yml's linux-riscv64 job): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust
|
||||
# target), CROSS_COMPILE (the binutils prefix, for strip) and EMULATOR (e.g. qemu-riscv64, to
|
||||
# smoke-test the CLI), and pass --formats tar.
|
||||
#
|
||||
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
|
||||
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
|
||||
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli,
|
||||
@@ -25,13 +29,14 @@ while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
--formats) FORMATS="$2"; shift 2 ;;
|
||||
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
|
||||
-h | --help) sed -n '2,19p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
ARCH="${CROSS_ARCH:-$(uname -m)}"
|
||||
case "$ARCH" in
|
||||
riscv64) DEB_ARCH=riscv64 ;;
|
||||
x86_64) DEB_ARCH=amd64 ;;
|
||||
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
|
||||
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
|
||||
@@ -42,9 +47,13 @@ BASENAME="vectorcraft-$VERSION-linux-$ARCH"
|
||||
echo "==> VectorCraft $VERSION for Linux $ARCH ($FORMATS)"
|
||||
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli)
|
||||
if [ -n "${CROSS_TARGET:-}" ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli --target "$CROSS_TARGET")
|
||||
else
|
||||
(cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli)
|
||||
fi
|
||||
fi
|
||||
BIN="$CARGO_TARGET_DIR/release"
|
||||
BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
|
||||
WORK="$CARGO_TARGET_DIR/linux-package"
|
||||
STAGE="$WORK/root"
|
||||
rm -rf "$WORK"
|
||||
@@ -52,7 +61,7 @@ rm -rf "$WORK"
|
||||
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
|
||||
install -Dm755 "$BIN/vectorcraft" "$STAGE/usr/bin/vectorcraft"
|
||||
install -Dm755 "$BIN/vectorcraft-cli" "$STAGE/usr/bin/vectorcraft-cli"
|
||||
strip "$STAGE/usr/bin/vectorcraft" "$STAGE/usr/bin/vectorcraft-cli" 2>/dev/null || true
|
||||
"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/vectorcraft" "$STAGE/usr/bin/vectorcraft-cli" 2>/dev/null || true
|
||||
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
|
||||
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
|
||||
mkdir -p "$STAGE/usr/share/metainfo"
|
||||
@@ -135,6 +144,10 @@ if has appimage; then
|
||||
fi
|
||||
fi
|
||||
|
||||
"$STAGE/usr/bin/vectorcraft-cli" --version
|
||||
if [ -z "${CROSS_TARGET:-}" ]; then
|
||||
"$STAGE/usr/bin/vectorcraft-cli" --version
|
||||
elif [ -n "${EMULATOR:-}" ]; then
|
||||
"$EMULATOR" "$STAGE/usr/bin/vectorcraft-cli" --version
|
||||
fi
|
||||
echo "==> done"
|
||||
ls -lh "$DIST"
|
||||
Reference in new issue
Block a user