diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 9dc8fe0b..9fcb2fb2 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,6 +27,13 @@ jobs: timeout-minutes: 300 steps: - uses: actions/checkout@v4 + # The optional craft-fonts build input (Japanese fonts), so the tests that need it run here + # rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md). + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - name: Build and test in a FreeBSD VM uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 with: @@ -44,5 +51,7 @@ jobs: # The checkout is synced in under another owner; let tests that call git read it. git config --global --add safe.directory "*" rustc --version + # The checkout above is synced into the VM with the workspace. + export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70ebb9c6..353df357 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,13 @@ jobs: VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + # 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin @@ -90,6 +97,8 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/macos/package.sh --arch universal - uses: actions/upload-artifact@v4 with: @@ -112,11 +121,21 @@ jobs: include: - { arch: x64, target: x86_64-pc-windows-msvc } - { arch: x86, target: i686-pc-windows-msvc } + # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. + # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. + - { arch: arm64, target: aarch64-pc-windows-msvc } env: VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }} VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + # 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -141,6 +160,8 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - uses: actions/upload-artifact@v4 with: @@ -167,6 +188,13 @@ jobs: NFPM_VERSION: 2.47.0 steps: - uses: actions/checkout@v4 + # 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - name: Install build and packaging tools run: | sudo apt-get update @@ -184,6 +212,9 @@ jobs: if: inputs.version != '' run: cargo xtask version set "$VECTORCRAFT_VERSION" - name: Build and package (AppImage, deb, rpm, tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/linux/package.sh - name: Check the packages run: | @@ -212,6 +243,13 @@ jobs: TRUNK_VERSION: 0.21.14 steps: - uses: actions/checkout@v4 + # 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown @@ -227,6 +265,9 @@ jobs: if: inputs.version != '' run: cargo xtask version set "$VECTORCRAFT_VERSION" - name: Build and zip + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml new file mode 100644 index 00000000..d1cb4c9c --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -0,0 +1,95 @@ +# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 +# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed +# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left +# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.) +name: Windows ARM64 + +on: + push: + branches: [main] + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - "**/Cargo.toml" + - "**/build.rs" + - .github/workflows/windows-arm64.yml + - packaging/windows/** + schedule: + - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up + workflow_dispatch: + +concurrency: + group: windows-arm64-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + package: + name: package (cross-compiled on x64) + runs-on: windows-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + with: + targets: aarch64-pc-windows-msvc + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: windows-arm64-package + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Build and package (unsigned) + shell: pwsh + env: + DIST: ${{ github.workspace }}\dist\arm64 + run: ./packaging/windows/package.ps1 -Arch arm64 + - uses: actions/upload-artifact@v4 + with: + name: vectorcraft-windows-arm64 + path: dist/arm64/* + if-no-files-found: error + retention-days: 7 + + install: + name: install and run on ARM64 + needs: package + runs-on: windows-11-arm + timeout-minutes: 30 + steps: + - uses: actions/download-artifact@v4 + with: + name: vectorcraft-windows-arm64 + path: dist + - name: Install the MSI, check it, run it, uninstall it + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName + $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } + $dir = Join-Path $env:ProgramFiles 'VectorCraft' + foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') { + $path = Join-Path $dir $exe + if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } + $bytes = [System.IO.File]::ReadAllBytes($path) + $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) + if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } + Write-Output "ok $path (ARM64)" + } + # The installed CLI runs natively on ARM64. + $cli = Join-Path $dir 'vectorcraft-cli.exe' + $out = & $cli --version 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" } + Write-Output "ok vectorcraft-cli --version: $($out.Trim())" + $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } + if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' } + Write-Output 'ok uninstalled' diff --git a/.gitignore b/.gitignore index 0d78b09d..8c493ff1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ plan/ /corpus /log .DS_Store +# A craft-fonts checkout for CRAFT_FONTS_DIR (CI and release workflows check it out here) +/craft-fonts diff --git a/AGENTS.md b/AGENTS.md index 5ed3a901..1dc0569b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Creative Commons with redistribution allowed. - **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`. - `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset. + - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. - Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft. - Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it. - Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets. diff --git a/ASSETS.md b/ASSETS.md index 6f5aa75f..77bd061a 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -6,6 +6,10 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`). +**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay. + +**Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them. + | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| | `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | @@ -46,8 +50,6 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/fonts/OFL-JetBrainsMono.txt` | (licence text) | upstream project | — | | | `assets/fonts/OFL-SourceSans3.txt` | (licence text) | upstream project | — | | | `assets/fonts/OFL-SourceSerif4.txt` | (licence text) | upstream project | — | | -| `assets/fonts/ShipporiMincho-Regular.ttf` | The Shippori Mincho Project Authors (FONTDASU) | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho | OFL-1.1 (`assets/fonts/OFL-ShipporiMincho.txt`) | Japanese UI/document fallback; SHA-256 `769b5269f0f9bc6534b352c0e6bd856a566e03ff788f107191c2d835863570b2` | -| `assets/fonts/OFL-ShipporiMincho.txt` | The Shippori Mincho Project Authors (FONTDASU) | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/OFL.txt | OFL-1.1 | Licence for Shippori Mincho | | `assets/icons/LICENSE-lucide.txt` | (licence text) | upstream project | — | | | `assets/icons/align-center-horizontal.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/align-center-vertical.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | diff --git a/CLAUDE.md b/CLAUDE.md index 5ed3a901..1dc0569b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Creative Commons with redistribution allowed. - **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`. - `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset. + - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. - Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft. - Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it. - Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets. diff --git a/Cargo.lock b/Cargo.lock index b5c65f73..00eaf3b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4220,6 +4220,8 @@ dependencies = [ "image", "log", "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "rfd", "serde_json", "vectorcraft-engine", diff --git a/NOTICE b/NOTICE index 089ff379..ba97f852 100644 --- a/NOTICE +++ b/NOTICE @@ -30,6 +30,7 @@ based in part on the work of the Independent JPEG Group. WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is under the Apache License 2.0 with the LLVM exception. -Shippori Mincho: Copyright 2021 The Shippori Mincho Project Authors -(https://github.com/fontdasu/ShipporiMincho). SIL Open Font License 1.1; -see assets/fonts/OFL-ShipporiMincho.txt. +Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the +fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ +UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL +Open Font License 1.1. Their license texts ship with the release packages as OFL-.txt. diff --git a/README.md b/README.md index ab1ca583..d0130516 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,10 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names ``` +Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional +build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path). +Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input). + ### Use it from Claude Code and other agents Register the MCP server with Claude Code: @@ -241,7 +245,9 @@ VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-AP Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. Required notices are in [NOTICE](NOTICE). Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed -with its author, source and license in [ASSETS.md](ASSETS.md). +with its author, source and license in [ASSETS.md](ASSETS.md). Release builds also embed the +Japanese fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) +(SIL Open Font License 1.1). The app icon (an engraved dragon on VectorCraft red, `#e8573f`) is the owner's original artwork; its palette and files are in [`assets/app-icon/`](assets/app-icon/README.md). diff --git a/ROADMAP.md b/ROADMAP.md index 804e018e..b8e77672 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,6 +95,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. + - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. @@ -114,7 +115,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. - **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system fonts. 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, upright glyphs centred on the column, point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; 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 `` and PDF soft masks. -- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). +- **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, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. @@ -126,6 +127,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. +- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export. - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). @@ -145,7 +147,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, 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, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -173,7 +175,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) |---|---:|---:|---|---:| | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 | -| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 | +| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder edge cases | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | @@ -191,7 +193,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | | Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | -| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 | +| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done) | 15–25 | | Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 | | **Feature parity** | **103** | **~75%** | | **~210–330** | | Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 6c6c2f26..eb6f472c 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -14,11 +14,41 @@ use std::io::Write; use std::process::ExitCode; +/// `println!` / `print!` that end the program quietly when stdout is closed +/// (`vectorcraft-cli commands | head`) instead of panicking with "failed printing to stdout: +/// Broken pipe (os error 32)". +macro_rules! outln { + ($($arg:tt)*) => {{ + use std::io::Write as _; + if let Err(e) = writeln!(std::io::stdout(), $($arg)*) { + $crate::stdout_failed(e); + } + }}; +} +macro_rules! out { + ($($arg:tt)*) => {{ + use std::io::Write as _; + if let Err(e) = write!(std::io::stdout(), $($arg)*) { + $crate::stdout_failed(e); + } + }}; +} + mod perf; use serde_json::{Value, json}; use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server}; +/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as +/// ripgrep does; any other write error is reported. +fn stdout_failed(e: std::io::Error) -> ! { + if e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + eprintln!("vectorcraft-cli: can't write to stdout: {e}"); + std::process::exit(1); +} + const USAGE: &str = "\ vectorcraft-cli — VectorCraft automation @@ -68,11 +98,11 @@ fn main() -> ExitCode { Some("bench") => bench(&args[1..]), Some("perf") => perf::run(&args[1..]), Some("-h" | "--help" | "help") | None => { - print!("{}", usage()); + out!("{}", usage()); Ok(()) } Some("-V" | "--version") => { - println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION")); + outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) } Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())), @@ -120,7 +150,7 @@ fn mcp(args: &[String]) -> Result<(), String> { fn commands() -> Result<(), String> { let mut h = Headless::new(); let v = h.call("engine.commands", json!({}))?; - println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); Ok(()) } @@ -146,7 +176,7 @@ fn convert(args: &[String]) -> Result<(), String> { json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}), ) .map_err(|e| format!("export {output}: {e}"))?; - println!("{r}"); + outln!("{r}"); Ok(()) } @@ -162,7 +192,7 @@ fn info(args: &[String]) -> Result<(), String> { let artboards: Vec = doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect(); let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts}); - println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); Ok(()) } @@ -252,7 +282,7 @@ fn bench(args: &[String]) -> Result<(), String> { * vectorcraft_geom::Affine::scale(z) * vectorcraft_geom::Affine::translate(-b.center().to_vec2()); let opts = vectorcraft_render::RenderOptions::default(); - println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::()); + outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::()); for threads in [vectorcraft_render::default_threads(), 0] { let mut r = vectorcraft_render::Renderer::new(); r.threads = threads; @@ -261,7 +291,7 @@ fn bench(args: &[String]) -> Result<(), String> { for _ in 0..iters { r.render(&doc, w, h, view, &opts); } - println!( + outln!( " threads {threads}: {:.1} ms/frame (drawn {}, culled {})", t.elapsed().as_secs_f64() * 1000.0 / iters as f64, r.stats.drawn, diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 2f4e5c11..f13b0cbe 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -81,14 +81,14 @@ pub fn run(args: &[String]) -> Result<(), String> { } let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); let load = load_average(); - println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}"))); + outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}"))); let noisy = load.is_some_and(|l| l > cores as f64 * 0.75); if noisy { - println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); + outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); } let t = Instant::now(); let doc = synthetic(n)?; - println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); + outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); let mut rows: Vec<(&str, f64, f64)> = vec![]; let opts = RenderOptions::default(); @@ -150,16 +150,16 @@ pub fn run(args: &[String]) -> Result<(), String> { } let mut over = 0; - println!(" {:<40} {:>10} {:>10}", "", "measured", "budget"); + outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget"); for (name, ms, budget) in &rows { let ok = ms <= budget; over += usize::from(!ok); - println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" }); + outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" }); } match (over, noisy) { (0, _) => Ok(()), (_, true) => { - println!("{over} over budget, but the machine is busy: re-run when idle."); + outln!("{over} over budget, but the machine is busy: re-run when idle."); Ok(()) } _ => Err(format!("{over} budget(s) exceeded")), diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 398f2880..1dfebe64 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -82,3 +82,17 @@ fn mcp_headless_over_stdio() { assert_eq!(replies[2]["result"]["isError"], false); assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); } + +/// `vectorcraft-cli commands | head -1` panicked with "failed printing to +/// stdout: Broken pipe (os error 32)" and exit status 101. +#[test] +fn closed_stdout_ends_quietly() { + use std::io::Read; + let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let mut first = [0u8; 16]; + child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap(); + drop(child.stdout.take()); + let out = child.wait_with_output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status); +} diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 06dba69a..045d5c69 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -30,6 +30,8 @@ clipboard-win = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] } [build-dependencies] # Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 21e53364..c203b22d 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -42,12 +42,27 @@ impl eframe::App for App { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); + #[cfg(target_os = "macos")] + if self.0.take_ime_discard() { + discard_marked_text(); + } } fn on_exit(&mut self) { save_prefs(&self.0); } } +/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as +/// typed). winit's IME toggle only clears its own copy, so the IME would type it again. +#[cfg(target_os = "macos")] +fn discard_marked_text() { + if let Some(mtm) = objc2::MainThreadMarker::new() + && let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm) + { + ic.discardMarkedText(); + } +} + /// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS), /// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux). fn prefs_path() -> Option { diff --git a/assets/fonts/OFL-ShipporiMincho.txt b/assets/fonts/OFL-ShipporiMincho.txt deleted file mode 100644 index a3f24cb5..00000000 --- a/assets/fonts/OFL-ShipporiMincho.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2021 The Shippori Mincho Project Authors (https://github.com/fontdasu/ShipporiMincho) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/ShipporiMincho-Regular.ttf b/assets/fonts/ShipporiMincho-Regular.ttf deleted file mode 100644 index cfc25ceb..00000000 Binary files a/assets/fonts/ShipporiMincho-Regular.ttf and /dev/null differ diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index dd608545..449b95f7 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAY use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use super::ImportOptions; -use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, TextItem}; +use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, Span, TextItem}; use super::reader::{Drawing, LayoutDef}; use crate::{CAP_HEIGHT, MAX_NEST}; @@ -244,14 +244,18 @@ impl<'d, 'a> Build<'d, 'a> { Geom::Text(t) => { let lines = t.text.lines().count().max(1) as f64; let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64; - let w = longest * t.height * 0.9 * t.width; + let natural = longest * t.height * 0.9 * t.width; + // Fit and aligned text run their span; aligned text's height scales with it. + let k = t.span.and_then(|sp| sp.scale(natural)).unwrap_or(1.0); + let height = if matches!(t.span, Some(Span::Aligned(_))) { t.height * k } else { t.height }; + let w = natural * k; let x0 = match t.justify { vectorcraft_doc::Justify::Center => -w / 2.0, vectorcraft_doc::Justify::Right => -w, _ => 0.0, }; - let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + t.height * 0.3; - Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, t.height))) + let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + height * 0.3; + Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, height))) } Geom::Insert(ins) => { let b = self.block_bounds(&ins.block, depth); @@ -502,6 +506,9 @@ impl<'d, 'a> Build<'d, 'a> { let mut obj = TextObject::point(Point::ZERO, &t.text, style); obj.xf = m * Affine::scale(1.0 / s); obj.para.justify = t.justify; + if let Some(span) = t.span { + fill_span(&mut obj, span, s); + } Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj)))) } @@ -581,6 +588,20 @@ impl<'d, 'a> Build<'d, 'a> { } } +/// Fit text stretched or squeezed across, aligned text scaled as a whole, to run its span +/// (`s`: points per drawing unit), measured in the font it is set in. +fn fill_span(obj: &mut TextObject, span: Span, s: f64) { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj); + let natural = layout.lines.iter().map(|l| l.x1 - l.x0).fold(0.0, f64::max); + let Some(k) = span.scale(natural / s) else { return }; + for r in &mut obj.runs { + match span { + Span::Fit(_) => r.style.h_scale *= k, + Span::Aligned(_) => r.style.size *= k, + } + } +} + fn rgb_color([r, g, b]: [u8; 3]) -> Color { Color::rgb8(r, g, b) } diff --git a/crates/cad/src/import/entity.rs b/crates/cad/src/import/entity.rs index fe5b95f0..cebb222d 100644 --- a/crates/cad/src/import/entity.rs +++ b/crates/cad/src/import/entity.rs @@ -19,6 +19,9 @@ const LINE_SPACING: f64 = 5.0 / 3.0; const MAX_CELLS: i64 = 10_000; /// Art farther than this from the origin (drawing units) is damaged data, left out. const MAX_COORD: f64 = 1e12; +/// How far fit and aligned text may be squeezed or stretched to span their two points: at width +/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows. +const SPAN_SCALE: (f64, f64) = (0.01, 100.0); /// The colour of index `i` on paper: index 7 (white on a CAD screen) is black. pub(crate) fn aci_paper(i: u64) -> [u8; 3] { @@ -121,6 +124,27 @@ pub(crate) struct TextItem { pub family: Option, /// Baseline-to-baseline distance (drawing units), for several lines. pub leading: Option, + /// The distance aligned and fit text run along their baseline. + pub span: Option, +} + +/// How text fills the distance between its two points (drawing units). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Span { + /// Stretched or squeezed across, its height kept. + Fit(f64), + /// Scaled as a whole, height included. + Aligned(f64), +} + +impl Span { + /// The scale that makes text `natural` long run the span, within [`SPAN_SCALE`] (None when + /// either length is degenerate). + pub fn scale(self, natural: f64) -> Option { + let (Self::Fit(d) | Self::Aligned(d)) = self; + let k = d / natural; + (k.is_finite() && k > 0.0).then(|| k.clamp(SPAN_SCALE.0, SPAN_SCALE.1)) + } } /// A block reference: one transform per copy (an array insert makes several), each from the @@ -608,10 +632,13 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option { let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0)); let (p1, p2) = (g.point(10), g.point_opt(11)); let mut angle = g.num_or(50, 0.0).to_radians(); + let mut span = None; // Aligned and fit text run from the first point to the second. let anchor = match (h, p2) { (3 | 5, Some(p)) => { angle = (p - p1).atan2(); + let d = p1.distance(p); + span = (d > 1e-12).then_some(if h == 3 { Span::Aligned(d) } else { Span::Fit(d) }); p1 } (0, _) if v == 0 => p1, @@ -636,6 +663,7 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option { style: g.name(7).unwrap_or("STANDARD").to_uppercase(), family: None, leading: None, + span, }) } @@ -662,5 +690,15 @@ fn mtext(g: &[Pair<'_>]) -> Option { let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12); let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2()); let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first)); - Some(TextItem { text: s, height, xf, width: 1.0, justify, style: g.name(7).unwrap_or("STANDARD").to_uppercase(), family, leading: Some(leading) }) + Some(TextItem { + text: s, + height, + xf, + width: 1.0, + justify, + style: g.name(7).unwrap_or("STANDARD").to_uppercase(), + family, + leading: Some(leading), + span: None, + }) } diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index a2ed7633..0e69cb54 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -397,6 +397,167 @@ fn text_and_multiline_text() { assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}"); } +/// Where the first line's baseline starts and ends, in document coordinates. +fn baseline_ends(t: &TextObject) -> (Point, Point) { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = &layout.lines[0]; + (t.xf * Point::new(l.x0, l.baseline), t.xf * Point::new(l.x1, l.baseline)) +} + +#[test] +fn fit_and_aligned_text_span_their_two_points() { + // Millimetres: red lines at x = 10 and 90, fit and aligned text between them, and the same + // text left-justified for its natural size. + let e = [ + (0, "LINE"), + (62, "1"), + (10, "10"), + (20, "0"), + (11, "10"), + (21, "60"), + (0, "LINE"), + (62, "1"), + (10, "90"), + (20, "0"), + (11, "90"), + (21, "60"), + (0, "TEXT"), + (10, "10"), + (20, "40"), + (40, "5"), + (1, "FIT BETWEEN"), + (72, "5"), + (11, "90"), + (21, "40"), + (0, "TEXT"), + (10, "10"), + (20, "10"), + (40, "5"), + (41, "0.8"), + (1, "ALIGNED"), + (72, "3"), + (11, "90"), + (21, "10"), + (0, "TEXT"), + (10, "10"), + (20, "25"), + (40, "5"), + (1, "FIT BETWEEN"), + (0, "TEXT"), + (10, "10"), + (20, "50"), + (40, "5"), + (41, "0.8"), + (1, "ALIGNED"), + ]; + let o = ImportOptions { center: false, ..ImportOptions::default() }; + let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document; + let [left, right, fit, aligned, fit_natural, aligned_natural] = &leaves(&doc)[..] else { panic!() }; + let (x10, x90) = (bounds(left).center().x, bounds(right).center().x); + for n in [fit, aligned] { + let (a, b) = baseline_ends(text_of(n)); + assert!((a.x - x10).abs() < 1e-3 && (b.x - x90).abs() < 1e-3, "{a:?} to {b:?}, not {x10} to {x90}"); + assert!((a.y - b.y).abs() < 1e-9, "level"); + } + // Fit keeps its height and stretches across. + let (st, natural) = (text_of(fit).first_style(), text_of(fit_natural).first_style()); + assert_eq!(st.size, natural.size); + assert!(st.h_scale > 100.0, "{}", st.h_scale); + // Aligned grows as a whole, its width factor kept. + let (a, b) = baseline_ends(text_of(aligned_natural)); + let k = (x90 - x10) / (b.x - a.x); + let (st, natural) = (text_of(aligned).first_style(), text_of(aligned_natural).first_style()); + assert!((st.size - natural.size * k).abs() < 1e-6 && k > 2.0, "{} vs {} × {k}", st.size, natural.size); + assert!((st.h_scale - 80.0).abs() < 1e-9 && (natural.h_scale - 80.0).abs() < 1e-9); +} + +#[test] +fn fit_and_aligned_text_without_a_second_point_keep_their_size() { + for h in ["3", "5"] { + let e = [ + (0, "TEXT"), + (10, "10"), + (20, "10"), + (40, "7"), + (1, "Gap"), + (72, h), + (0, "TEXT"), + (10, "10"), + (20, "30"), + (40, "7"), + (1, "Gap"), + (72, h), + (11, "10"), + (21, "30"), + ]; + let doc = open(&entities(&e), &points()).document; + let top = height(&doc); + for (n, y) in leaves(&doc).iter().zip([10.0, 30.0]) { + let t = text_of(n); + let st = t.first_style(); + assert!((st.size - 10.0).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale); + assert!((t.xf * Point::ZERO).distance(Point::new(10.0, top - y)) < 1e-9); + } + } +} + +#[test] +fn fit_and_aligned_text_scale_within_limits() { + // A span a billion units long and one a millionth of a unit long. + let e = [ + (0, "TEXT"), + (10, "0"), + (20, "0"), + (40, "7"), + (1, "X"), + (72, "5"), + (11, "1e9"), + (21, "0"), + (0, "TEXT"), + (10, "0"), + (20, "10"), + (40, "7"), + (1, "X"), + (72, "3"), + (11, "1e-6"), + (21, "10"), + ]; + let doc = open(&entities(&e), &points()).document; + let [fit, aligned] = &leaves(&doc)[..] else { panic!() }; + let st = text_of(fit).first_style(); + assert!((st.size - 10.0).abs() < 1e-9 && (st.h_scale - 10_000.0).abs() < 1e-6, "{} at {}%", st.size, st.h_scale); + let st = text_of(aligned).first_style(); + assert!((st.size - 0.1).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale); +} + +#[test] +fn fit_attributes_span_their_two_points() { + let inserts = [ + (0, "INSERT"), + (66, "1"), + (2, "Door"), + (10, "0"), + (20, "0"), + (0, "ATTRIB"), + (10, "10"), + (20, "20"), + (40, "7"), + (1, "D-01"), + (2, "TAG"), + (70, "0"), + (72, "5"), + (74, "0"), + (11, "90"), + (21, "20"), + (0, "SEQEND"), + ]; + let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document; + let tag = leaves(&doc).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap(); + let (a, b) = baseline_ends(text_of(&tag)); + assert!((a.x - 10.0).abs() < 1e-3 && (b.x - 90.0).abs() < 1e-3, "{a:?} to {b:?}"); + assert_eq!(text_of(&tag).first_style().size, 10.0); +} + /// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`. fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec { let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")]; diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 12c4f491..a9e83390 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -16,7 +16,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; -use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _}; +use vectorcraft_geom::{FillRule, Point, Rect, Shape as _}; use vectorcraft_pathops as po; use vectorcraft_tools::builder::{ self as b, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, edge_near, face_at, is_live_paint, sample_polyline, shapes_for, sorted_roots, @@ -205,52 +205,6 @@ fn stroke_only(paint: Paint, width: f64) -> Appearance { Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], ..Default::default() } } -/// A point strictly inside a filled path (for re-finding faces after a rebuild). -pub(crate) fn interior_point(path: &PathData) -> Option { - let bb = path.bounds()?; - let bp = path.to_bezpath(); - let mut edges: Vec<(Point, Point)> = vec![]; - let (mut first, mut last) = (Point::ZERO, Point::ZERO); - kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el { - kurbo::PathEl::MoveTo(p) => { - if last != first { - edges.push((last, first)); - } - first = p; - last = p; - } - kurbo::PathEl::LineTo(p) => { - edges.push((last, p)); - last = p; - } - kurbo::PathEl::ClosePath => { - if last != first { - edges.push((last, first)); - } - last = first; - } - _ => {} - }); - if last != first { - edges.push((last, first)); - } - let mut best: Option<(f64, Point)> = None; - for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] { - let y = bb.y0 + bb.height() * f; - let mut xs: Vec = - edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect(); - xs.sort_by(f64::total_cmp); - for w in xs.windows(2) { - let m = Point::new((w[0] + w[1]) / 2.0, y); - let width = w[1] - w[0]; - if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 { - best = Some((width, m)); - } - } - } - best.map(|b| b.1) -} - // ---------- Shape Builder ---------- fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result> { @@ -395,7 +349,11 @@ fn build_children(d: &mut Document, sources: Vec>) -> Result<(Vec>) -> Result<(Vec Result { let keep_faces: Vec<(Point, Paint)> = b::faces(&old) .iter() .filter(|f| !f.appearance.fill_paint().is_none()) - .filter_map(|f| Some((interior_point(f.path_data()?)?, f.appearance.fill_paint()))) + .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) .collect(); let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) .iter() diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 363d56a9..3c070f6c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -116,10 +116,21 @@ fn set_units(s: &mut Session, p: &Value) -> Result { ok() } +/// A typing session in progress (the Type tool previews the whole session as one interaction). +fn typing_in_progress(s: &Session) -> bool { + s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some()) +} + fn undo(s: &mut Session, _: &Value) -> Result { // What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes // back only that and Redo brings it back, instead of dropping it and undoing the step before. + // A typing session also ends in the Type tool, whose caret and marked text (IME) must follow + // the document. + let typing = typing_in_progress(s); s.commit_interaction()?; + if typing { + s.set_tool_option("endTyping", &Value::Bool(true)); + } let st = s.doc_mut()?; let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let label = e.label.clone(); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index e07ac02f..1d3c66a7 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}", has_doc, apply ), @@ -130,6 +130,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default(); + if let Some(loose) = loose_for_pathfinder(s, p, id)? { + let gid = s.edit(&label, |d, sel| { + let gid = super::object::group_nodes(d, &loose)?; + d.node_mut(gid).ok_or(EngineError::NoNode(gid))?.appearance.effects.push(effect.clone()); + sel.set([gid]); + Ok(gid) + })?; + return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true })); + } let item = item_target(s, p, C)?; let mut index = 0; let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| { @@ -143,6 +152,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed })) } +/// The selected objects a Pathfinder effect should group before it applies: it combines the +/// contents of a group or layer, so on several loose objects (none a group) it would do nothing. +/// `None` when the effect isn't one, objects are named (`ids`) or another target is asked for. +fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result>> { + if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) { + return Ok(None); + } + let roots = super::appearance::subject_roots(s)?; + let d = &s.doc()?.doc; + let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }))); + Ok((roots.len() >= 2 && !grouped).then_some(roots)) +} + fn list(s: &mut Session, p: &Value) -> Result { let catalog: Vec = effects::effect_catalog() .into_iter() @@ -507,6 +529,34 @@ mod tests { assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}"); } + #[test] + fn pathfinder_effect_on_loose_objects_groups_them_first() { + let (mut s, a) = session_with_rect(); + let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); + assert_eq!(r["grouped"], true); + let g = NodeId(r["ids"][0].as_u64().unwrap()); + let gn = node(&s, g); + assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::>(), [a, b], "stacking order kept"); + assert!(vectorcraft_render::effects::has_pathfinder(&gn)); + assert_eq!(s.doc().unwrap().selection.objects, [g]); + // Live: one united shape, so expanding leaves a single path. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().len(), 1); + // Grouping and applying are one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(g).is_none()); + assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b)); + // A selection holding a group applies to each object as before; so does a single object. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap(); + assert!(r.get("grouped").is_none()); + assert_eq!(node(&s, a).appearance.effects.len(), 1); + } + #[test] fn pathfinder_effect_on_a_group_renders_live_and_expands() { let (mut s, a) = session_with_rect(); diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 424c274f..9f1e016d 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} → [{family, style, runs, objects, missing}] sorted by name", + "{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", has_doc, fonts ), @@ -26,7 +26,7 @@ pub fn specs() -> Vec { "Replace Font", [], None, - "{from: {family, style?}, to: {family, style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}", has_doc, replace ), @@ -96,12 +96,12 @@ fn fonts(s: &mut Session, p: &Value) -> Result { let ids = scope(s, bool_or(p, "selectionOnly", false))?; let d = &s.doc()?.doc; let db = vectorcraft_text::FontDb::global(); - let families = db.families(); - let mut found: BTreeMap<(String, String), (usize, Vec)> = BTreeMap::new(); + let mut found: BTreeMap<(String, String), (usize, Vec, String)> = BTreeMap::new(); for id in ids { for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() { let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default(); e.0 += 1; + e.2.push_str(&r.text); if !e.1.contains(&id) { e.1.push(id); } @@ -109,10 +109,20 @@ fn fonts(s: &mut Session, p: &Value) -> Result { } let list: Vec = found .into_iter() - .map(|((family, style), (runs, objects))| { - let have_family = families.iter().any(|f| f.eq_ignore_ascii_case(&family)); - let missing = !have_family || !db.styles(&family).iter().any(|st| st.eq_ignore_ascii_case(&style)); - json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": missing }) + .map(|((family, style), (runs, objects, chars))| { + let resolved = db.resolve(&family, &style); + let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); + let mut lacking: Vec = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); + lacking.sort_unstable(); + lacking.dedup(); + lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + json!({ + "family": family, "style": style, "runs": runs, "objects": objects.len(), + "missing": status == vectorcraft_text::FontMatch::Missing, + "status": status.as_str(), + "resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })), + "missingGlyphs": lacking.len(), + }) }) .collect(); Ok(json!(list)) @@ -130,9 +140,11 @@ fn replace(s: &mut Session, p: &Value) -> Result { let (ff, fs) = font_param(p, "from", C)?; let (tf, ts) = font_param(p, "to", C)?; let db = vectorcraft_text::FontDb::global(); - if !db.families().iter().any(|f| f.eq_ignore_ascii_case(&tf)) { - return Err(bad(C, format!("font family `{tf}` is not available"))); - } + // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans). + let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) { + Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(), + _ => return Err(bad(C, format!("font family `{tf}` is not available"))), + }; let ids = scope(s, bool_or(p, "selectionOnly", false))?; let matches = |st: &vectorcraft_doc::CharStyle| { st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x)) @@ -261,6 +273,39 @@ mod tests { mod open_tests { use super::*; + #[test] + fn list_says_how_each_font_resolved() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + for (text, font, style) in + [("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black"), ("Gone", "No Such Font Family", "Regular")] + { + s.execute("text.create", &json!({"x": 10, "y": 20, "text": text, "font": font, "style": style})).unwrap(); + } + let list = s.execute("text.fonts", &json!({})).unwrap(); + let rows: Vec<(String, String, String, String)> = list + .as_array() + .unwrap() + .iter() + .map(|f| { + ( + f["style"].as_str().unwrap().into(), + f["status"].as_str().unwrap().into(), + f["resolved"]["family"].as_str().unwrap().into(), + f["resolved"]["style"].as_str().unwrap().into(), + ) + }) + .collect(); + let row = |style: &str| rows.iter().find(|r| r.0 == style).cloned().unwrap(); + assert_eq!(row("Semibold").1, "exact"); + assert_eq!(row("Black").1, "substitute", "{rows:?}"); + assert_eq!(row("Black").2, "Source Sans 3"); + assert_eq!((row("Regular").1.as_str(), row("Regular").2.as_str()), ("missing", vectorcraft_text::FALLBACK_FAMILY)); + s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab 雅楽", "font": "Inter"})).unwrap(); + let inter = s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|f| f["family"] == "Inter").cloned().unwrap(); + assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}"); + } + #[test] fn opened_documents_have_exact_text_bounds() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 0b80ea93..35c5bf06 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -375,20 +375,26 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result { fn group(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; - let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) }; let gid = s.edit("Group", |d, sel| { - let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; - let gid = d.alloc_id(); - d.insert(par, idx + 1, Node::group(gid, vec![]))?; - for id in &ids { - d.move_node(*id, Some(gid), usize::MAX)?; - } + let gid = group_nodes(d, &ids)?; sel.set([gid]); Ok(gid) })?; Ok(json!({ "id": gid.0 })) } +/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was. +pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result { + let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) }; + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let gid = d.alloc_id(); + d.insert(par, idx + 1, Node::group(gid, vec![]))?; + for id in ids { + d.move_node(*id, Some(gid), usize::MAX)?; + } + Ok(gid) +} + fn ungroup(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; s.edit("Ungroup", |d, sel| { diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index bb8dc6ff..2753d1a9 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -310,6 +310,56 @@ fn live_paint_bucket_tool_fills_face() { assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]); } +fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId { + let r = s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +/// Crossing lines, as in the bug report: the Live Paint Bucket fills the area they enclose. +#[test] +fn live_paint_bucket_fills_an_area_enclosed_by_lines() { + let mut s = session(); + let ids = [line(&mut s, (0.0, 0.0), (100.0, 100.0)), line(&mut s, (100.0, 0.0), (0.0, 100.0)), line(&mut s, (-10.0, 80.0), (110.0, 80.0))]; + s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 1.0)); + // Inside the triangle (20,80) (50,50) (80,80): highlighted, then filled. + s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 70.0), v).unwrap(); + assert!(!s.overlays(v).is_empty(), "red face highlight"); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap(); + let g = s.doc().unwrap().selection.objects[0]; + let gn = node(&s, g); + assert!(vectorcraft_tools::builder::is_live_paint(&gn)); + assert_eq!(vectorcraft_tools::builder::faces(&gn).len(), 1); + let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(50.0, 70.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), s.paint.fill); + assert!((vectorcraft_pathops::area(f.path_data().unwrap(), FillRule::NonZero).abs() - 900.0).abs() < 1e-6); + // The lines are edges, cut where they cross, and keep their stroke. + let edges = vectorcraft_tools::builder::edges(&gn); + assert_eq!(edges.len(), 9); + assert!(edges.iter().all(|e| !e.appearance.stroke_paint().is_none())); + // Outside the triangle there's nothing to fill. + assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 20]})).is_err()); +} + +#[test] +fn a_line_across_a_live_paint_shape_splits_its_face() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + set_fill(&mut s, r, Color::rgb(1.0, 0.0, 0.0)); + let l = line(&mut s, (40.0, -20.0), (60.0, 70.0)); + s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap(); + let made = s.execute("livePaint.make", &json!({})).unwrap(); + assert_eq!(made["faces"], 2, "{made}"); + let g = NodeId(made["id"].as_u64().unwrap()); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [80, 25], "color": "#0000ff"})).unwrap(); + let gn = node(&s, g); + let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(x, 25.0)).unwrap().appearance.fill_paint(); + assert_eq!(face(10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the left half keeps the rectangle's fill"); + assert_eq!(face(80.0), Paint::solid(Color::from_hex("#0000ff").unwrap())); +} + // ---------- Image Trace ---------- /// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2. diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index cd98697b..0462cfd8 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -157,6 +157,98 @@ fn undo_during_a_drag_takes_back_only_the_drag() { assert_eq!(b.x0, 100.0, "back where it was: {b:?}"); } +#[test] +fn ime_composition_shows_in_place_and_commits_as_one_step() { + // Romaji → kana → conversion → commit, as the macOS Japanese IME sends it. + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + let created = undo_labels(&s).len(); + s.tool_text("曲:", v).unwrap(); + for (t, r) in [("g", 1..1), ("が", 1..1), ("がg", 2..2), ("ががく", 3..3)] { + s.tool_preedit(t, Some(r), v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), format!("曲:{t}"), "marked text lays out in place"); + assert!(s.tool_composing()); + } + // Conversion: the active clause is the whole word. + s.tool_preedit("雅楽", Some(0..2), v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "曲:雅楽"); + assert_eq!(s.tool_options()["composing"], json!(true)); + // macOS clears the marked text, then commits. + s.tool_preedit("", None, v).unwrap(); + s.tool_text("雅楽", v).unwrap(); + assert!(!s.tool_composing()); + assert_eq!(obj(&s, id).plain_text(), "曲:雅楽"); + assert_eq!(s.tool_options()["caret"], json!("曲:雅楽".len())); + // Typing goes on after the commit, in the same session. + s.tool_text("。", v).unwrap(); + key(&mut s, ToolKey::Escape, Mods::default()); + assert_eq!(obj(&s, id).plain_text(), "曲:雅楽。"); + assert_eq!(undo_labels(&s).len(), created + 1, "one undo step for the session"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), ""); +} + +#[test] +fn ime_cancel_leaves_no_undo_step_and_keeps_the_text() { + let mut s = session(); + let v = ViewInfo::default(); + let id = text(&mut s, "雅楽"); + s.select_tool("type", v).unwrap(); + let t = obj(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let (a, b) = vectorcraft_text::caret_position(&lay, "雅楽".len()); + let p = t.xf * a.midpoint(b); + click(&mut s, p.x - 0.1, p.y); + assert!(s.tool_wants_text()); + s.set_tool_option("select", &json!({"start": 6, "end": 6})); + let before = undo_labels(&s).len(); + s.tool_preedit("えんそう", Some(4..4), v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "雅楽えんそう"); + // Escape in the IME: the marked text goes, nothing else changes. + s.tool_preedit("", None, v).unwrap(); + assert!(!s.tool_composing()); + assert!(!s.in_interaction(), "a cancelled composition leaves no typing session"); + assert_eq!(obj(&s, id).plain_text(), "雅楽"); + assert_eq!(undo_labels(&s).len(), before); + // A stray clear (no composition) never deletes the selection. + s.set_tool_option("select", &json!({"start": 0, "end": 3})); + s.tool_preedit("", None, v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "雅楽"); + // A composition over a selection replaces it. + s.tool_preedit("が", Some(1..1), v).unwrap(); + s.tool_preedit("", None, v).unwrap(); + s.tool_text("我", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "我楽"); +} + +#[test] +fn clicking_away_keeps_marked_text_as_typed_and_undo_mid_composition_is_safe() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_preedit("しょうこ", Some(4..4), v).unwrap(); + // Keys belong to the IME while it composes. + key(&mut s, ToolKey::Backspace, Mods::default()); + assert_eq!(obj(&s, id).plain_text(), "しょうこ"); + click(&mut s, 600.0, 500.0); + assert!(!s.tool_composing()); + assert_eq!(obj(&s, id).plain_text(), "しょうこ", "the marked text stays, committed"); + // The UI holds Undo back while composing; if it comes anyway, the session is committed first. + click(&mut s, 50.0, 50.0); + let id2 = s.doc().unwrap().selection.objects[0]; + s.tool_preedit("ひちりき", Some(4..4), v).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.tool_composing()); + assert_eq!(obj(&s, id2).plain_text(), ""); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(obj(&s, id2).plain_text(), "ひちりき"); +} + #[test] fn type_tool_selection_replace_and_delete() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 738929de..27402ec1 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -214,6 +214,24 @@ impl Session { self.tool.wants_text() } + /// IME marked text for the active tool (see `Tool::ime_preedit`; `active_chars` counts + /// characters). + pub fn tool_preedit(&mut self, text: &str, active_chars: Option>, view: ViewInfo) -> Result> { + let acts = self.with_tool_cx(view, |t, cx| t.ime_preedit(cx, text, active_chars)); + self.take_tool_panic()?; + self.apply_actions(acts) + } + + /// Is the active tool showing uncommitted IME text? + pub fn tool_composing(&self) -> bool { + self.tool.composing() + } + + /// The caret line (document space) the IME candidate window follows. + pub fn tool_ime_caret(&mut self, view: ViewInfo) -> Option<(Point, Point)> { + self.with_tool_cx(view, |t, cx| t.ime_caret(cx)) + } + pub fn tool_busy(&self) -> bool { self.tool.busy() } diff --git a/crates/pathops/README.md b/crates/pathops/README.md index 5454f1c3..67427039 100644 --- a/crates/pathops/README.md +++ b/crates/pathops/README.md @@ -1,6 +1,6 @@ # vectorcraft-pathops -Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`. +Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, Live Paint planar maps, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`. ## Booleans (curve-preserving) @@ -49,6 +49,15 @@ Each result takes its key as follows: | Crop | Keeps the visible parts of the lower objects inside the front-most object | | Outline | Returns open edge paths, split at every junction; coincident edges are de-duplicated, and each keeps the front-most key | +## Live Paint + +```rust +pub fn live_paint(shapes: &[Shape] /* back → front */) -> (Vec, Vec); // (faces, edges) +pub fn interior_point(path: &PathData) -> Option; +``` + +Unlike `regions`, which splits the filled areas of closed shapes, `live_paint` treats every path as an edge, open paths included. Three crossing lines enclose a triangle, a line across a rectangle splits it in two, and an area enclosed by paths becomes a face even when nothing fills it. Each face lists the inputs whose fill covers it; the list is empty for an area that is only enclosed by paths. Each edge is a piece of a path, split wherever another path meets it, and keeps the key of the front-most input it lies on. + ## Offset / stroke ```rust diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index d37e1f31..677424c7 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -157,7 +157,7 @@ fn el_points(el: &kurbo::PathEl) -> impl Iterator { /// handle within `eps` of its anchor's height is levelled with it (no y-extremum is left in a /// sub-`eps` sliver of a curve). Anchors move by less than `eps` and handles by less than `2·eps`, /// the order of the sweep's own tolerance. -fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) { +pub(crate) fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) { use kurbo::PathEl::*; let mut ys: Vec = paths.iter().flat_map(|p| p.elements().iter().filter_map(kurbo::PathEl::end_point)).map(|p| p.y).collect(); ys.sort_by(f64::total_cmp); @@ -339,7 +339,7 @@ pub(crate) fn all_contours_to_path(c: &Contours, tidy: &Tidy) -> PathData { /// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris /// (e.g. the lens left between two coincident curves) and are dropped, like Illustrator's /// Pathfinder precision setting does. -fn is_sliver(bp: &BezPath, precision: f64) -> bool { +pub(crate) fn is_sliver(bp: &BezPath, precision: f64) -> bool { let a = bp.area().abs(); let per = bp.perimeter(1e-6); per <= 0.0 || 2.0 * a / per < precision diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 7f9b367f..7d56bb95 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -11,6 +11,7 @@ mod edit; mod fit; mod offset; mod pathfinder; +mod planar; pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all}; pub use edit::{ @@ -18,6 +19,7 @@ pub use edit::{ }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; +pub use planar::{interior_point, live_paint}; /// Errors from fallible operations. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs new file mode 100644 index 00000000..bdc6274b --- /dev/null +++ b/crates/pathops/src/planar.rs @@ -0,0 +1,815 @@ +//! Live Paint planar maps: the faces and edges a set of paths divides the plane into. +//! +//! Unlike Pathfinder [`regions`](crate::regions), which splits the *filled areas* of closed shapes, +//! every path here is an edge, open ones included: three crossing lines enclose a triangle, a line +//! across a rectangle splits it in two, and an area enclosed by paths is a face whether or not +//! anything fills it. +//! +//! The sweep (`linesweeper`) only takes closed paths, so an open path goes in traced there and +//! back. Its two passes would cancel out as winding numbers, so the sweep counts boundary crossings +//! instead ([`Crossings`]), which never cancel. Faces are then walked on the planar graph of the +//! sweep's output segments; which inputs an edge came from and which fills cover a face are found +//! geometrically (the sweep's winding numbers can't be trusted once open paths and coincident +//! edges are in). +//! +//! The sweep runs in a sheared frame ([`SHEAR`]): it merges the two passes of a *horizontal* open +//! path as if they cancelled out, and in the sheared frame no edge drawn at a common angle (0°, +//! 45°, 90°…) is horizontal. The shear is by a power of two, so it maps typical coordinates +//! exactly and keeps exact coincidences (a corner on another shape's edge) exact. + +use std::collections::HashMap; + +use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _}; +use linesweeper::topology::{Topology, WindingNumber}; +use vectorcraft_geom::{FillRule, PathData}; + +use crate::boolean::{DEFAULT_PRECISION, Seg, Tidy, eps_for, is_sliver, segs_to_subpath, snap_horizontals, tidy_segments, to_seg}; +use crate::pathfinder::{Region, Shape}; + +/// The sweep's frame: y + x / 32. Only edges sloping -1/32 in the document are horizontal in it. +const SHEAR: Affine = Affine::new([1.0, 1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]); +const UNSHEAR: Affine = Affine::new([1.0, -1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]); + +/// Boundary crossings per input, counted from the far west: slot `2i` counts input `i`'s +/// segments running one way, slot `2i + 1` the other. An open path traced there and back adds one +/// to each, so its edges never cancel out of the map. +#[derive(Clone, Debug, Default)] +struct Crossings(Vec); + +impl Crossings { + fn get(&self, slot: usize) -> u32 { + self.0.get(slot).copied().unwrap_or(0) + } +} + +impl PartialEq for Crossings { + fn eq(&self, other: &Self) -> bool { + (0..self.0.len().max(other.0.len())).all(|i| self.get(i) == other.get(i)) + } +} +impl Eq for Crossings {} + +impl std::ops::Add for Crossings { + type Output = Crossings; + fn add(mut self, rhs: Self) -> Crossings { + self += rhs; + self + } +} + +impl std::ops::AddAssign for Crossings { + fn add_assign(&mut self, rhs: Self) { + if self.0.len() < rhs.0.len() { + self.0.resize(rhs.0.len(), 0); + } + for (a, b) in self.0.iter_mut().zip(rhs.0) { + *a = a.saturating_add(b); + } + } +} + +impl WindingNumber for Crossings { + type Tag = usize; + fn single(tag: usize, positive: bool) -> Self { + let slot = 2 * tag + usize::from(!positive); + let mut v = vec![0; slot + 1]; + if let Some(c) = v.get_mut(slot) { + *c = 1; + } + Crossings(v) + } + fn of_tag(&self, tag: usize) -> Self { + let mut v = vec![0; 2 * tag + 2]; + for slot in [2 * tag, 2 * tag + 1] { + if let Some(c) = v.get_mut(slot) { + *c = self.get(slot); + } + } + Crossings(v) + } +} + +/// A point in the middle of a run of segments. +fn mid(segs: &[Seg]) -> Option { + segs.get(segs.len() / 2).map(|s| s.c.eval(0.5)) +} + +/// The inputs' segments (in the sweep's frame), to tell which inputs an edge lies on. +struct Outlines { + segs: Vec<(kurbo::Rect, PathSeg, usize)>, + tol: f64, +} + +impl Outlines { + fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self { + let segs = inputs.iter().flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i))).collect(); + // The sweep moves points by a few `eps` at most. + Self { segs, tol: 16.0 * eps } + } + + /// The front-most input whose outline passes through `p`. + fn source_at(&self, p: Point) -> Option { + use kurbo::ParamCurveNearest; + let tol = self.tol; + self.segs.iter().filter(|(r, s, _)| r.inflate(tol, tol).contains(p) && s.nearest(p, 1e-9).distance_sq <= tol * tol).map(|(_, _, i)| *i).max() + } +} + +/// The area an input's fill covers: its closed subpaths (open ones bound no face). +struct Fill { + path: BezPath, + bounds: kurbo::Rect, + rule: FillRule, +} + +impl Fill { + fn new(s: &Shape) -> Self { + let mut path = BezPath::new(); + for sp in s.path.subpaths.iter().filter(|sp| sp.closed && sp.anchors.len() >= 2) { + sp.to_bezpath_into(&mut path); + } + Self { bounds: path.bounding_box(), path, rule: s.rule } + } + + fn contains(&self, p: Point) -> bool { + !self.path.elements().is_empty() && self.bounds.contains(p) && crate::boolean::inside(self.rule, self.path.winding(p)) + } +} + +/// One output segment of the sweep: an edge of the planar graph between two vertices. +struct Edge { + segs: Vec, + ends: [usize; 2], + /// The input it came from (the front-most, where inputs share it). + source: usize, +} + +/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it. +fn twin(h: usize) -> usize { + h ^ 1 +} + +/// The segments of half-edge `h`, in its direction. +fn half_segs(edges: &[Edge], h: usize) -> Vec { + let Some(e) = edges.get(h / 2) else { return Vec::new() }; + if h.is_multiple_of(2) { + e.segs.clone() + } else { + e.segs.iter().rev().map(|s| Seg { c: CubicBez::new(s.c.p3, s.c.p2, s.c.p1, s.c.p0), line: s.line }).collect() + } +} + +/// The vertex half-edge `h` leaves from. +fn origin(edges: &[Edge], h: usize) -> Option { + edges.get(h / 2).map(|e| e.ends[h % 2]) +} + +/// Where half-edge `h` (leaving `v`) first gets `r` away from it, as an angle around `v`. +/// +/// Edges that only meet at `v` cross a circle around it in the order they leave `v` in, however +/// they start out (tangent curves, or the sweep's short straight stubs at a vertex). +fn leaving_angle(edges: &[Edge], h: usize, v: Point, r: f64) -> f64 { + let segs = half_segs(edges, h); + let mut bp = BezPath::new(); + for (k, s) in segs.iter().enumerate() { + if k == 0 { + bp.move_to(s.c.p0); + } + bp.curve_to(s.c.p1, s.c.p2, s.c.p3); + } + let mut prev = segs.first().map_or(v, |s| s.c.p0); + let mut hit: Option = None; + kurbo::flatten(bp.iter(), (r * 1e-4).max(1e-12), |el| { + if let (None, kurbo::PathEl::LineTo(p)) = (hit, el) { + if p.distance(v) >= r { + // The point on prev → p at distance r (prev is closer). + let (d, f) = (p - prev, prev - v); + let (a, b, c) = (d.hypot2(), 2.0 * f.dot(d), f.hypot2() - r * r); + let t = if a > 0.0 { ((-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a)).clamp(0.0, 1.0) } else { 1.0 }; + hit = Some(prev + d * t); + } + prev = p; + } + }); + // An edge too short to reach r (it can't, r is at most half of every chord) leaves along its + // tangent. + let far = + hit.unwrap_or_else(|| segs.first().map_or(v, |s| [s.c.p1, s.c.p2, s.c.p3].into_iter().find(|p| p.distance(s.c.p0) > 0.0).unwrap_or(s.c.p3))); + (far - v).atan2() +} + +/// Path of a closed run of half-edges, tidied (split pieces of one input curve joined again). +fn cycle_bezpath(edges: &[Edge], cycle: &[usize]) -> BezPath { + let mut bp = BezPath::new(); + for (k, s) in cycle.iter().flat_map(|&h| half_segs(edges, h)).enumerate() { + if k == 0 { + bp.move_to(s.c.p0); + } + if s.line { + bp.line_to(s.c.p3); + } else { + bp.curve_to(s.c.p1, s.c.p2, s.c.p3); + } + } + bp.close_path(); + bp +} + +/// `cycle` without the spurs it runs out and back along (edges with the same face on both sides, +/// such as a line's loose end). +fn without_spurs(cycle: &[usize]) -> Vec { + let mut out: Vec = Vec::with_capacity(cycle.len()); + for &h in cycle { + if out.last() == Some(&twin(h)) { + out.pop(); + } else { + out.push(h); + } + } + // A spur where the walk started wraps around the ends. + while out.len() >= 2 && out.first().map(|&h| twin(h)) == out.last().copied() { + out.pop(); + out.remove(0); + } + out +} + +/// Append `open` traced there and back (a closed, zero-area path along it) to `out`. +fn there_and_back(open: &BezPath, out: &mut BezPath) { + let segs: Vec = open.segments().collect(); + let Some(first) = segs.first() else { return }; + out.move_to(first.start()); + for s in &segs { + out.push(s.as_path_el()); + } + for s in segs.iter().rev() { + out.push(s.reverse().as_path_el()); + } + out.close_path(); +} + +/// `path` back from the sweep's frame, its anchors within `tol` of an input anchor (`sorted` by x) +/// put exactly on it, handles moving with them. +fn unshear(mut path: PathData, sorted: &[Point], tol: f64) -> PathData { + path.transform(UNSHEAR); + for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) { + let lo = sorted.partition_point(|q| q.x < a.p.x - tol); + if let Some(q) = sorted.get(lo..).unwrap_or_default().iter().take_while(|q| q.x <= a.p.x + tol).find(|q| (q.y - a.p.y).abs() <= tol) { + let d = *q - a.p; + a.p = *q; + a.h_in += d; + a.h_out += d; + } + } + path +} + +fn find(parent: &mut [usize], mut v: usize) -> usize { + while let Some(&p) = parent.get(v) { + if p == v { + break; + } + let gp = parent.get(p).copied().unwrap_or(p); + if let Some(slot) = parent.get_mut(v) { + *slot = gp; + } + v = p; + } + v +} + +/// The Live Paint planar map of `shapes` (back → front). +/// +/// Faces are the bounded areas the paths enclose, holes included, each with the inputs whose fill +/// covers it (ascending; empty for an area only enclosed by paths). Edges are the paths split +/// wherever they meet another path, keyed by the input they came from (the front-most where +/// inputs share one). Empty when the paths can't be swept (non-finite or too degenerate). +pub fn live_paint(shapes: &[Shape]) -> (Vec, Vec) { + build(shapes).unwrap_or_default() +} + +fn build(shapes: &[Shape]) -> Option<(Vec, Vec)> { + let mut inputs: Vec<(BezPath, usize)> = Vec::new(); + for (i, s) in shapes.iter().enumerate() { + let mut bp = BezPath::new(); + for sp in s.path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) { + if sp.closed { + sp.to_bezpath_into(&mut bp); + } else { + let mut open = BezPath::new(); + sp.to_bezpath_into(&mut open); + there_and_back(&open, &mut bp); + } + } + if !bp.elements().is_empty() { + bp.apply_affine(SHEAR); + inputs.push((bp, i)); + } + } + if inputs.is_empty() { + return None; + } + let eps = eps_for(inputs.iter().map(|p| &p.0)).ok()?; + snap_horizontals(&mut inputs.iter_mut().map(|p| &mut p.0).collect::>(), eps); + // The sweep can panic on rare near-degenerate input; that must never take the app down. + let sweep = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps))); + let top = sweep.ok()?.ok()?; + let tidy = Tidy::keeping(DEFAULT_PRECISION, inputs.iter().map(|p| &p.0)); + + // The planar graph: vertices are the sweep's points, edges its segments. Points closer than + // the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge + // a rounding error away from where it cuts that edge). + let positions = top.compute_positions(); + let cell = 4.0 * eps; + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + let mut points: Vec = Vec::new(); + let mut vertex_id = |p: Point| { + let (cx, cy) = ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64); + let near = (cx - 1..=cx + 1) + .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) + .filter_map(|k| grid.get(&k)) + .flatten() + .copied() + .find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps)); + near.unwrap_or_else(|| { + points.push(p); + grid.entry((cx, cy)).or_default().push(points.len() - 1); + points.len() - 1 + }) + }; + let mut edges: Vec = Vec::new(); + let mut between: HashMap<(usize, usize), Vec> = HashMap::new(); + let outlines = Outlines::new(&inputs, eps); + for idx in top.segment_indices() { + let half = idx.first_half(); + let segs: Vec = positions[idx].path.segments().map(to_seg).collect(); + let ends = [vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())]; + if segs.is_empty() || ends[0] == ends[1] { + continue; + } + let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue }; + // Coincident pieces the sweep left apart are one edge (the front-most input's). + let key = (ends[0].min(ends[1]), ends[0].max(ends[1])); + let here = mid(&segs); + let twin_of = between.get(&key).and_then(|v| { + v.iter().copied().find(|&e| { + let other = edges.get(e).and_then(|e| mid(&e.segs)); + matches!((here, other), (Some(a), Some(b)) if a.distance(b) < DEFAULT_PRECISION) + }) + }); + if let Some(e) = twin_of { + if let Some(e) = edges.get_mut(e) + && source > e.source + { + e.source = source; + } + continue; + } + between.entry(key).or_default().push(edges.len()); + edges.push(Edge { segs, ends, source }); + } + let nv = points.len(); + + // Half-edges leaving each vertex, sorted by direction; walking a face, the next half-edge is + // the one just clockwise of the way back, so bounded faces come out with positive area and + // the outer boundary of each connected piece with negative area. + let mut around: Vec> = vec![Vec::new(); nv]; + for h in 0..2 * edges.len() { + if let Some(list) = origin(&edges, h).and_then(|v| around.get_mut(v)) { + list.push(h); + } + } + let mut slot_of = vec![0usize; 2 * edges.len()]; + for (v, list) in around.iter_mut().enumerate() { + let Some(&at) = points.get(v) else { continue }; + // Half the shortest chord of the edges here: every one of them gets that far from it. + let r = 0.5 * list.iter().filter_map(|&h| half_segs(&edges, h).last().map(|s| s.c.p3.distance(at))).fold(f64::INFINITY, f64::min); + let mut keyed: Vec<(f64, usize)> = list.iter().map(|&h| (leaving_angle(&edges, h, at, r), h)).collect(); + keyed.sort_by(|a, b| a.0.total_cmp(&b.0)); + *list = keyed.into_iter().map(|(_, h)| h).collect(); + for (k, &h) in list.iter().enumerate() { + if let Some(s) = slot_of.get_mut(h) { + *s = k; + } + } + } + let next = |h: usize| -> Option { + let back = twin(h); + let list = around.get(origin(&edges, back)?)?; + let k = *slot_of.get(back)?; + list.get((k + list.len() - 1) % list.len()).copied() + }; + + // Connected pieces of the graph. + let mut parent: Vec = (0..nv).collect(); + for e in &edges { + let (a, b) = (find(&mut parent, e.ends[0]), find(&mut parent, e.ends[1])); + if let Some(p) = parent.get_mut(a) { + *p = b; + } + } + let piece: Vec = (0..nv).map(|v| find(&mut parent, v)).collect(); + let piece_of = |cycle: &[usize]| cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| piece.get(v)).copied(); + + // Walk every cycle: positive ones are faces, negative ones outer boundaries (holes in the + // face around them, if any). + let mut seen = vec![false; 2 * edges.len()]; + let mut faces: Vec<(Vec, BezPath, f64)> = Vec::new(); + let mut holes: Vec<(Vec, BezPath)> = Vec::new(); + for start in 0..2 * edges.len() { + if seen.get(start).copied().unwrap_or(true) { + continue; + } + let mut cycle = vec![]; + let mut h = start; + while let Some(s) = seen.get_mut(h).filter(|s| !**s) { + *s = true; + cycle.push(h); + match next(h) { + Some(n) => h = n, + None => break, + } + } + let cycle = without_spurs(&cycle); + if cycle.is_empty() { + continue; + } + let bp = cycle_bezpath(&edges, &cycle); + let area = bp.area(); + if area > 0.0 && !is_sliver(&bp, tidy.precision) { + faces.push((cycle, bp, area)); + } else if area < -(tidy.precision * tidy.precision) { + holes.push((cycle, bp)); + } + } + + // Each hole goes in the smallest face of another piece around it. + let mut face_holes: Vec> = vec![Vec::new(); faces.len()]; + for (k, (cycle, _)) in holes.iter().enumerate() { + let Some(p) = cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| points.get(v)).copied() else { continue }; + let own = piece_of(cycle); + let around_it = faces + .iter() + .enumerate() + .filter(|(_, (fc, bp, _))| piece_of(fc) != own && bp.bounding_box().contains(p) && bp.winding(p) != 0) + .min_by(|a, b| a.1.2.total_cmp(&b.1.2)) + .map(|(i, _)| i); + if let Some(list) = around_it.and_then(|i| face_holes.get_mut(i)) { + list.push(k); + } + } + + let fills: Vec = shapes.iter().map(Fill::new).collect(); + // The inputs' anchors, which results land on again up to the sweep's tolerance. + let snap = 2.0 * eps; + let mut anchors: Vec = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect(); + anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + let subpath = |cycle: &[usize]| { + let segs: Vec = cycle.iter().flat_map(|&h| half_segs(&edges, h)).collect(); + segs_to_subpath(&tidy_segments(segs, true, &tidy), true) + }; + let regions = faces + .iter() + .zip(&face_holes) + .filter_map(|((cycle, _, _), hs)| { + let mut subpaths = vec![subpath(cycle)?]; + subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c))); + let path = unshear(PathData::new(subpaths), &anchors, snap); + let sources = + interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default(); + Some(Region { path, sources }) + }) + .collect(); + + // Edges: chains of segments through vertices where nothing else meets. + let degree: Vec = around.iter().map(Vec::len).collect(); + let passes = |v: usize| -> Option<(usize, usize)> { + let list = around.get(v)?; + let (&a, &b) = (list.first()?, list.get(1)?); + (degree.get(v) == Some(&2) && edges.get(a / 2)?.source == edges.get(b / 2)?.source).then_some((a, b)) + }; + let mut used = vec![false; edges.len()]; + let mut chains: Vec<(Vec, bool, usize)> = Vec::new(); + // A chain from half-edge `h0` up to the next vertex where it doesn't pass straight through; + // closed when it comes back round to where it started. + let walk = |h0: usize, used: &mut Vec| -> Option<(Vec, bool, usize)> { + let source = edges.get(h0 / 2)?.source; + let mut segs = Vec::new(); + let mut h = h0; + loop { + let u = used.get_mut(h / 2)?; + if *u { + return Some((segs, true, source)); + } + *u = true; + segs.extend(half_segs(&edges, h)); + let v = origin(&edges, twin(h))?; + let Some((a, b)) = passes(v) else { return Some((segs, origin(&edges, h0) == Some(v), source)) }; + h = if a == twin(h) { b } else { a }; + } + }; + for v in 0..nv { + if passes(v).is_some() { + continue; + } + for &h in around.get(v).map(Vec::as_slice).unwrap_or_default() { + if !used.get(h / 2).copied().unwrap_or(true) + && let Some(c) = walk(h, &mut used) + { + chains.push(c); + } + } + } + // What's left are closed loops with no junction on them. + for e in 0..edges.len() { + if !used.get(e).copied().unwrap_or(true) + && let Some(c) = walk(2 * e, &mut used) + { + chains.push(c); + } + } + let edge_shapes = chains + .into_iter() + .filter_map(|(segs, closed, source)| { + let sp = segs_to_subpath(&tidy_segments(segs, closed, &tidy), closed)?; + Some(Shape::new(unshear(PathData::new(vec![sp]), &anchors, snap), FillRule::NonZero, shapes.get(source)?.key)) + }) + .collect(); + Some((regions, edge_shapes)) +} + +/// A point strictly inside a filled path, well away from its edges where it can be (for re-finding +/// a Live Paint face after a rebuild). +pub fn interior_point(path: &PathData) -> Option { + let bb = path.bounds()?; + let bp = path.to_bezpath(); + let mut edges: Vec<(Point, Point)> = vec![]; + let (mut first, mut last) = (Point::ZERO, Point::ZERO); + kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el { + kurbo::PathEl::MoveTo(p) => { + if last != first { + edges.push((last, first)); + } + first = p; + last = p; + } + kurbo::PathEl::LineTo(p) => { + edges.push((last, p)); + last = p; + } + kurbo::PathEl::ClosePath => { + if last != first { + edges.push((last, first)); + } + last = first; + } + _ => {} + }); + if last != first { + edges.push((last, first)); + } + let mut best: Option<(f64, Point)> = None; + for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] { + // A little off the round fractions, which on art drawn to a grid fall on the height of + // some corner, where testing the point against other paths is unreliable. + let y = bb.y0 + bb.height() * (f + 0.0061803); + let mut xs: Vec = + edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect(); + xs.sort_by(f64::total_cmp); + for w in xs.windows(2) { + let m = Point::new((w[0] + w[1]) / 2.0, y); + let width = w[1] - w[0]; + if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 { + best = Some((width, m)); + } + } + } + best.map(|b| b.1) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use vectorcraft_geom::{Rect, SubPath, shapes}; + + fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape { + Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key) + } + + fn rect(x0: f64, y0: f64, x1: f64, y1: f64, key: u64) -> Shape { + Shape::new(shapes::rectangle(Rect::new(x0, y0, x1, y1)), FillRule::NonZero, key) + } + + fn area(r: &Region) -> f64 { + r.path.to_bezpath().area() + } + + fn at(faces: &[Region], x: f64, y: f64) -> Vec<&Region> { + faces.iter().filter(|f| f.contains(Point::new(x, y))).collect() + } + + #[test] + fn crossing_lines_enclose_a_face() { + // Three lines crossing pairwise, their ends sticking out: one triangle. + let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 1), line((-10.0, 80.0), (110.0, 80.0), 2)]; + let (faces, edges) = live_paint(&s); + assert_eq!(faces.len(), 1, "{faces:?}"); + let f = &faces[0]; + assert!((area(f) - 900.0).abs() < 1e-6, "triangle (20,80) (50,50) (80,80): {}", area(f)); + assert!(f.sources.is_empty(), "nothing fills it yet"); + assert_eq!(f.path.subpaths.len(), 1); + assert_eq!(f.path.subpaths[0].anchors.len(), 3, "the loose ends are not part of it"); + // Each line is cut where the others cross it: 3 + 3 + 3 pieces. + assert_eq!(edges.len(), 9); + assert_eq!(edges.iter().filter(|e| e.key == 2).count(), 3); + } + + #[test] + fn a_grid_of_lines_makes_its_cells() { + let mut s = vec![]; + for k in 0..4 { + let v = f64::from(k) * 10.0; + s.push(line((v, -5.0), (v, 35.0), 0)); + s.push(line((-5.0, v), (35.0, v), 1)); + } + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 9); + assert!(faces.iter().all(|f| (area(f) - 100.0).abs() < 1e-6)); + assert_eq!(at(&faces, 15.0, 15.0).len(), 1); + } + + #[test] + fn a_line_across_a_shape_splits_it() { + let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)]; + let (faces, edges) = live_paint(&s); + assert_eq!(faces.len(), 2); + assert!(faces.iter().all(|f| f.sources == [0]), "both halves keep the rectangle's fill"); + assert!((faces.iter().map(area).sum::() - 5000.0).abs() < 1e-6); + assert_eq!(at(&faces, 10.0, 25.0).len(), 1); + assert_eq!(at(&faces, 90.0, 25.0).len(), 1); + // The rectangle's outline in two pieces, the line in three. + assert_eq!(edges.iter().filter(|e| e.key == 7).count(), 2); + assert_eq!(edges.iter().filter(|e| e.key == 8).count(), 3); + } + + #[test] + fn overlapping_shapes_keep_their_coverage() { + let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)]; + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 3); + assert_eq!(at(&faces, 25.0, 50.0)[0].sources, [0]); + assert_eq!(at(&faces, 75.0, 50.0)[0].sources, [0, 1]); + assert_eq!(at(&faces, 125.0, 50.0)[0].sources, [1]); + // Same faces as the Pathfinder regions. + let regions = crate::regions(&s); + assert_eq!(regions.len(), 3); + for f in &faces { + assert!(regions.iter().any(|r| r.sources == f.sources && (area(r).abs() - area(f)).abs() < 1e-6)); + } + } + + #[test] + fn a_closed_loop_of_lines_inside_a_shape_is_a_hole_and_a_face() { + let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)]; + let sq = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0), (40.0, 40.0)]; + for w in sq.windows(2) { + s.push(line(w[0], w[1], 1)); + } + // A loose line inside: no face of its own, no hole. + s.push(line((10.0, 10.0), (20.0, 20.0), 2)); + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 2, "{faces:?}"); + let outer = at(&faces, 5.0, 5.0); + assert_eq!(outer.len(), 1); + assert_eq!(outer[0].path.subpaths.len(), 2, "the inner square is a hole in it"); + assert!((area(outer[0]) - 9600.0).abs() < 1e-6); + let inner = at(&faces, 50.0, 50.0); + assert_eq!(inner.len(), 1, "the hole doesn't count as the outer face"); + assert_eq!(inner[0].sources, [0], "the rectangle fills it"); + assert!((area(inner[0]) - 400.0).abs() < 1e-6); + } + + #[test] + fn an_area_enclosed_by_shapes_is_an_unfilled_face() { + // A frame of four bars around a 50 × 50 gap. + let s = [rect(0.0, 0.0, 70.0, 10.0, 0), rect(0.0, 60.0, 70.0, 70.0, 0), rect(0.0, 0.0, 10.0, 70.0, 0), rect(60.0, 0.0, 70.0, 70.0, 0)]; + let (faces, _) = live_paint(&s); + let gap = at(&faces, 35.0, 35.0); + assert_eq!(gap.len(), 1); + assert!(gap[0].sources.is_empty()); + assert!((area(gap[0]) - 2500.0).abs() < 1e-6); + } + + #[test] + fn curves_and_a_circle_crossed_by_a_line() { + let circle = Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), FillRule::NonZero, 0); + let (faces, edges) = live_paint(&[circle, line((-10.0, 50.0), (110.0, 50.0), 1)]); + assert_eq!(faces.len(), 2); + for f in &faces { + assert!((area(f) - std::f64::consts::PI * 2500.0 / 2.0).abs() < 2.0, "{}", area(f)); + assert!(f.path.subpaths[0].anchors.len() <= 4, "no extra anchors from the sweep's splits"); + } + assert_eq!(edges.iter().filter(|e| e.key == 0).count(), 2, "two arcs"); + } + + #[test] + fn a_line_ending_on_an_edge_splits_the_shape() { + let s = [line((90.0, 20.0), (70.0, 10.0), 0), rect(80.0, 10.0, 90.0, 50.0, 1)]; + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 2); + assert!((area(&faces[0]) + area(&faces[1]) - 400.0).abs() < 1e-6); + for f in &faces { + let p = crate::interior_point(&f.path).unwrap(); + assert_eq!(at(&faces, p.x, p.y).len(), 1, "{p:?}"); + assert_eq!(f.sources, [1]); + } + } + + #[test] + fn nothing_or_garbage_gives_an_empty_map() { + assert_eq!(live_paint(&[]), (vec![], vec![])); + assert!(live_paint(&[line((0.0, 0.0), (f64::NAN, 1.0), 0)]).0.is_empty()); + let (faces, edges) = live_paint(&[line((0.0, 0.0), (10.0, 10.0), 0)]); + assert!(faces.is_empty()); + assert_eq!(edges.len(), 1); + } + + fn arb_segment() -> impl Strategy { + let c = || (0..10i32).prop_map(|v| f64::from(v) * 10.0); + prop_oneof![ + (c(), c(), c(), c()).prop_map(|(a, b, x, y)| line((a, b), (x, y), 0)), + (c(), c(), 1..5i32, 1..5i32).prop_map(|(x, y, w, h)| rect(x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0, 0)), + (c(), c(), 1..5i32).prop_map(|(x, y, r)| { + let r = f64::from(r) * 10.0; + Shape::new(shapes::ellipse(Rect::new(x, y, x + r, y + r)), FillRule::EvenOdd, 0) + }), + ] + } + + proptest! { + #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] + + /// Faces never overlap, and a face's sources are exactly the inputs whose fill holds a + /// point inside it. + #[test] + fn faces_are_disjoint_and_know_what_covers_them(shapes in prop::collection::vec(arb_segment(), 1..7)) { + let (faces, _) = live_paint(&shapes); + for f in &faces { + prop_assert!(area(f) > 0.0); + let Some(p) = crate::interior_point(&f.path) else { continue }; + prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1, "{:?} in more than one face", p); + let covering: Vec = shapes + .iter() + .enumerate() + .filter(|(_, s)| s.path.is_closed() && vectorcraft_geom::hit::fill_contains(&s.path.to_bezpath(), s.rule, p)) + .map(|(i, _)| i) + .collect(); + prop_assert_eq!(&f.sources, &covering, "at {:?}", p); + } + } + + /// Closed shapes alone: the filled faces cover exactly the Pathfinder regions. + #[test] + fn filled_faces_are_the_pathfinder_regions(shapes in prop::collection::vec(arb_segment(), 1..6)) { + let closed: Vec = shapes.into_iter().filter(|s| s.path.is_closed()).collect(); + let (faces, _) = live_paint(&closed); + let filled: f64 = faces.iter().filter(|f| !f.sources.is_empty()).map(area).sum(); + let regions: f64 = crate::regions(&closed).iter().map(|r| area(r).abs()).sum(); + // Both refit split curves to [`DEFAULT_PRECISION`], so curved areas may differ by up + // to that much along every outline. + let outlines: f64 = closed.iter().map(|s| s.path.to_bezpath().perimeter(1e-6)).sum(); + prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions); + } + + /// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics. + #[test] + fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) { + let lines: Vec = pts.into_iter().map(|(a, b, x, y)| line((a, b), (x, y), 0)).collect(); + let (faces, edges) = live_paint(&lines); + prop_assert!(edges.len() >= lines.len().min(1)); + for f in &faces { + prop_assert!(f.sources.is_empty()); + if let Some(p) = crate::interior_point(&f.path) { + prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1); + } + } + } + } + + #[test] + fn many_lines_stay_quick() { + // 60 lines through a 100 pt square: about 900 crossings. + let lines: Vec = (0..60) + .map(|k| { + let t = f64::from(k) * 0.37; + line((50.0 + 80.0 * t.cos(), 50.0 + 80.0 * t.sin()), (50.0 - 80.0 * (t + 1.1).cos(), 50.0 - 80.0 * (t + 1.1).sin()), 0) + }) + .collect(); + let started = std::time::Instant::now(); + let (faces, _) = live_paint(&lines); + assert!(faces.len() > 500, "{}", faces.len()); + assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + } +} diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 5230d5c3..dc7df0fe 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -206,6 +206,10 @@ impl Families { Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest, _ => name, }; + // An installed face of that exact PostScript name: its own family and style. + if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) { + return (family, style, true); + } let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, "")); let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned()); let style = match spaced(strip_ps(style)).as_str() { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b89e498b..baae4cd1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1488,8 +1488,14 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { let mut runs = vec![BezPath::new(); t.runs.len()]; let mut all = BezPath::new(); // Per run: is its family missing, and the face its glyphs should come from. - let faces: Vec<(bool, Option)> = - t.runs.iter().map(|r| (!db.has_family(&r.style.font_family), db.face(&r.style.font_family, &r.style.font_style).map(|f| f.id()))).collect(); + let faces: Vec<(bool, Option)> = t + .runs + .iter() + .map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) { + Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())), + None => (true, None), + }) + .collect(); let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new()); for g in &layout.glyphs { if let Some(r) = runs.get_mut(g.run) { diff --git a/crates/text/build.rs b/crates/text/build.rs new file mode 100644 index 00000000..1bdbbd31 --- /dev/null +++ b/crates/text/build.rs @@ -0,0 +1,74 @@ +//! The optional craft-fonts build input (https://github.com/storytold/craft-fonts, +//! `docs/integration.md`): with `CRAFT_FONTS_DIR=` set, every font in its +//! `fonts/manifest.txt` is embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty and the app +//! uses its own and the installed fonts. `CRAFT_FONTS_REQUIRED=1` turns a bad checkout into a build +//! error (release builds). Nothing is fetched: the build reads only the local checkout. +//! +//! Web (wasm32) builds embed only the UI font, BIZ UDPGothic Regular (~4.7 MB): the browser has no +//! system fonts, and all four fonts (~24 MB) would push the `.wasm` past static hosts' per-file +//! limits (Cloudflare Pages: 25 MiB). + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +fn main() { + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); + let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { + let dir = from_workspace(dir); + match craft_fonts(&dir) { + Ok(entries) => src.push_str(&entries), + Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { + println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); + } + Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + src.push_str("];\n"); + let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); + if let Err(e) = std::fs::write(&out, src) { + println!("cargo::error=writing {}: {e}", out.display()); + } +} + +/// A relative `CRAFT_FONTS_DIR` is taken from the workspace root (where `cargo` is usually run), +/// not from this crate's directory, which is where build scripts run. +fn from_workspace(dir: PathBuf) -> PathBuf { + if dir.is_absolute() { + return dir; + } + let crate_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()); + crate_dir.join("../..").join(dir) +} + +/// The one craft-fonts face web builds embed (family, style). +const WEB_FONT: (&str, &str) = ("BIZ UDPGothic", "Regular"); + +/// One `CraftFont { .. }` initialiser per manifest line. +fn craft_fonts(dir: &Path) -> Result { + let manifest = dir.join("fonts/manifest.txt"); + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = String::new(); + for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { + let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); + let [family, style, file, scripts, ..] = f.as_slice() else { + return Err(format!("malformed manifest line: {line}")); + }; + if wasm && !(*family == WEB_FONT.0 && *style == WEB_FONT.1) { + continue; + } + let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; + println!("cargo::rerun-if-changed={}", path.display()); + let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); + let _ = writeln!( + out, + " CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},", + scripts.join(", "), + path.display().to_string(), + ); + } + Ok(out) +} diff --git a/crates/text/src/craft_fonts.rs b/crates/text/src/craft_fonts.rs new file mode 100644 index 00000000..00b94270 --- /dev/null +++ b/crates/text/src/craft_fonts.rs @@ -0,0 +1,52 @@ +//! Fonts from the optional craft-fonts build input (). +//! +//! Built with `CRAFT_FONTS_DIR=`, [`CRAFT_FONTS`] holds every font in its +//! manifest (today the Japanese UI and document fonts); built without it, it is empty and Japanese +//! text falls back to the installed system fonts. Release builds always set it. See `build.rs` +//! and craftrules `standards/fonts.md`. + +/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). +pub struct CraftFont { + pub family: &'static str, + pub style: &'static str, + /// ISO 15924 scripts the font is for, e.g. `"Jpan"`. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], +} + +include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); + +impl CraftFont { + /// Is the font for Japanese text? + pub fn is_japanese(&self) -> bool { + self.scripts.contains(&"Jpan") + } + /// A serif (Mincho) family, for document text. + pub fn is_mincho(&self) -> bool { + self.family.contains("Mincho") + } + /// A sans (Gothic) family, for UI text. + pub fn is_gothic(&self) -> bool { + self.family.contains("Gothic") + } + /// A bold face. + pub fn is_bold(&self) -> bool { + crate::style_weight(self.style) >= 600.0 + } +} + +/// The craft-fonts faces for Japanese text, best for document text first: Mincho (serif) families, +/// then the others, regular weights before bold. Empty when built without craft-fonts. +pub fn japanese_document_fonts() -> Vec<&'static CraftFont> { + let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect(); + v.sort_by_key(|f| (!f.is_mincho(), f.is_bold())); + v +} + +/// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then +/// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts. +pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> { + let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect(); + v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold)); + v +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 08765f4a..14c4f8d4 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -16,9 +16,6 @@ use skrifa::{GlyphId, MetadataProvider}; /// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; -/// Bundled Japanese font bytes, shared with UI glyph fallback. -pub static SHIPPORI_MINCHO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/ShipporiMincho-Regular.ttf"); - static BUNDLED: &[&[u8]] = &[ include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"), include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"), @@ -29,7 +26,6 @@ static BUNDLED: &[&[u8]] = &[ include_bytes!("../../../assets/fonts/Inter-Medium.ttf"), include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"), include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"), - SHIPPORI_MINCHO_REGULAR, ]; enum FontBytes { @@ -39,6 +35,8 @@ enum FontBytes { /// One loaded font face. pub struct FontFace { + /// Every name the face answers to (normalized), in every language its name table has. + keys: FaceKeys, id: u32, /// Typographic family name (e.g. "Source Sans 3"). pub family: String, @@ -157,14 +155,21 @@ struct CatalogFamily { /// [`FontDb::face`] does). Always empty on wasm, which has no system fonts. type Catalog = HashMap; +/// The installed faces' (family, style) by normalized PostScript name ([`norm`]). +type PostScriptNames = HashMap; + /// Process-wide font database. pub struct FontDb { faces: RwLock>>, outlines: Mutex>>, catalog: RwLock, + /// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai). + postscript: RwLock, /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] font_dirs: Vec, + /// The installed fonts' other names (localized, legacy, PostScript) by normalized name. + aliases: RwLock>>, /// Set once the font folders have been scanned. Lookups by family name wait for the first /// scan, so what they find never depends on what ran before them. #[cfg(not(target_arch = "wasm32"))] @@ -221,6 +226,116 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) } +/// Every localized string of `id`, with its language. +fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option, String)> { + font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect() +} + +/// Every localized string of `id`. +fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec { + localized(font, id).into_iter().map(|(_, s)| s).collect() +} + +/// The names a face answers to besides its (English) family and style, normalized ([`norm`]): +/// its family in every language (typographic, else legacy: `ヒラギノ角ゴシック`), its style likewise +/// (`ミディアム`), its legacy family names (`Hiragino Sans W3`) with their legacy styles, paired per +/// language record (the Mac and Windows records of a face differ), and its PostScript names +/// (`HiraginoSans-W3`, as PDF and `.ai` files name fonts). +#[derive(Clone, Debug, Default)] +struct FaceKeys { + families: Vec, + styles: Vec, + legacy: Vec<(String, String)>, + postscript: Vec, +} + +impl FaceKeys { + fn of(font: &skrifa::FontRef<'_>, family: &str, style: &str) -> Self { + let (typo_family, typo_style) = (all_names(font, StringId::TYPOGRAPHIC_FAMILY_NAME), all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME)); + let (legacy_family, legacy_style) = (localized(font, StringId::FAMILY_NAME), localized(font, StringId::SUBFAMILY_NAME)); + // Without typographic names, the legacy ones are the family's. + let family_names: Vec<&str> = if typo_family.is_empty() { + legacy_family.iter().map(|(_, f)| f.as_str()).collect() + } else { + typo_family.iter().map(String::as_str).collect() + }; + let mut families: Vec = std::iter::once(family).chain(family_names).map(norm).collect(); + // A legacy style only names the face beside its legacy family when the font has a + // typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family. + let style_names: Vec<&str> = if typo_style.is_empty() { + legacy_style.iter().map(|(_, s)| s.as_str()).collect() + } else { + typo_style.iter().map(String::as_str).collect() + }; + let mut styles: Vec = std::iter::once(style).chain(style_names).map(norm).collect(); + let mut legacy: Vec<(String, String)> = if typo_family.is_empty() { + vec![] + } else { + legacy_family + .iter() + .flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s)))) + .filter(|(f, s)| !f.is_empty() && !s.is_empty()) + .collect() + }; + let mut postscript: Vec = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect(); + for v in [&mut families, &mut styles, &mut postscript] { + v.retain(|k| !k.is_empty()); + v.sort(); + v.dedup(); + } + legacy.sort(); + legacy.dedup(); + FaceKeys { families, styles, legacy, postscript } + } +} + +/// What a name other than a family's own one stands for ([`FontDb::canonical`]). +#[derive(Clone, Debug)] +struct Alias { + /// The family's own name. + family: String, + /// The face the name names, for legacy family names and PostScript names. + style: Option, + /// A legacy family name's legacy style (`Regular` beside `Hiragino Sans W3`). + paired: Option, +} + +impl Alias { + /// The aliases of a face `family` / `style` with `keys`, by normalized name. + fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> { + let mut v: Vec<(String, Alias)> = + keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect(); + v.extend( + keys.legacy + .iter() + .map(|(f, s)| (f.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: Some(s.clone()) })), + ); + v.extend(keys.postscript.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: None }))); + v + } +} + +/// How a requested family and style resolved ([`FontDb::resolve`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FontMatch { + /// The face of that family and style (by any of its names, in any language). + Exact, + /// The family is there but not the style: its closest style stands in. + Style, + /// The family is unknown: the fallback family stands in. + Missing, +} + +impl FontMatch { + pub fn as_str(self) -> &'static str { + match self { + FontMatch::Exact => "exact", + FontMatch::Style => "substitute", + FontMatch::Missing => "missing", + } + } +} + /// A face's (family, style) names. fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; @@ -243,10 +358,11 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { .collect() } -/// (family, style) of every face in the font file at `path`, reading only its table directories -/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. +/// (family, style, PostScript name) of every face in the font file at `path`, reading only its +/// table directories and `name` tables: a scan opens hundreds of font files, many of them +/// megabytes long. #[cfg(not(target_arch = "wasm32"))] -fn file_face_names(path: &Path) -> Vec<(String, String)> { +fn file_face_names(path: &Path) -> Vec<(String, String, FaceKeys)> { use std::io::{Read, Seek, SeekFrom}; /// Caps on what a (possibly damaged) file can make the scan read. const MAX_FACES: u32 = 256; @@ -288,7 +404,10 @@ fn file_face_names(path: &Path) -> Vec<(String, String)> { font.extend_from_slice(&u32::to_be_bytes(v)); } font.extend_from_slice(&table); - face_names(&skrifa::FontRef::new(&font).ok()?) + let f = skrifa::FontRef::new(&font).ok()?; + let (family, style) = face_names(&f)?; + let keys = FaceKeys::of(&f, &family, &style); + Some((family, style, keys)) }) .collect() } @@ -305,6 +424,13 @@ pub fn system_font_dirs() -> Vec { if let Some(h) = &home { dirs.push(h.join("Library/Fonts")); } + // Fonts macOS downloads on demand (Yu Gothic, Yu Mincho, Tsukushi Maru Gothic…): one + // asset folder per font, under a catalog folder whose number changes with macOS. + for e in std::fs::read_dir("/System/Library/AssetsV2").into_iter().flatten().flatten() { + if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") { + dirs.push(e.path()); + } + } } else if cfg!(windows) { let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); dirs.push(root.join("Fonts")); @@ -330,7 +456,9 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: let m = f.metrics(Size::unscaled(), LocationRef::default()); let a = f.attributes(); let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?); + let keys = FaceKeys::of(&f, &family, &style); Some(FontFace { + keys, id: NEXT_ID.fetch_add(1, Ordering::Relaxed), family, style, @@ -387,7 +515,10 @@ impl FontDb { /// A database holding the bundled fonts, whose system font scan reads `font_dirs`. pub fn with_font_dirs(font_dirs: Vec) -> Self { let mut faces = Vec::new(); - for data in BUNDLED { + // The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho + // first: fallbacks for Japanese text after the requested and bundled fonts. + let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes); + for data in BUNDLED.iter().copied().chain(craft) { for (i, family, style) in enumerate_faces(data) { if let Some(f) = make_face(FontBytes::Static(data), i, family, style, None) { faces.push(Arc::new(f)); @@ -398,7 +529,9 @@ impl FontDb { faces: RwLock::new(faces), outlines: Mutex::new(HashMap::new()), catalog: RwLock::new(Catalog::new()), + postscript: RwLock::new(PostScriptNames::new()), font_dirs, + aliases: RwLock::new(HashMap::new()), #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock::new(), family_cache: Mutex::new(None), @@ -471,6 +604,8 @@ impl FontDb { /// Style names available for `family`: upright styles by weight, then italics. pub fn styles(&self, family: &str) -> Vec { + let (family, _) = self.canonical(family, ""); + let family = family.as_str(); let mut v: Vec<(bool, f32, String)> = self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect(); if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) { @@ -544,6 +679,8 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { let mut catalog = Catalog::new(); + let mut aliases: HashMap> = HashMap::new(); + let mut postscript = PostScriptNames::new(); let mut n = 0; let mut stack = self.font_dirs.clone(); // Each folder once, however links lead back to it. @@ -563,7 +700,14 @@ impl FontDb { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } - for (family, style) in file_face_names(&p) { + for (family, style, keys) in file_face_names(&p) { + for (k, a) in Alias::of(&family, &style, &keys) { + aliases.entry(k).or_default().push(a); + } + // `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up. + for ps in &keys.postscript { + postscript.insert(ps.clone(), (family.clone(), style.clone())); + } let entry = catalog.entry(family.to_ascii_lowercase()).or_default(); if entry.name.is_empty() { entry.name = family; @@ -575,10 +719,85 @@ impl FontDb { } log::debug!("cataloged {n} system font faces"); *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog; + *self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases; + *self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript; self.changed(); n } + /// The family's own name and the style for a name given in a document: any of a family's + /// names (`ヒラギノ角ゴシック` is Hiragino Sans), a legacy family name (`Hiragino Sans W6` with its + /// legacy style names the W6 face) or a PostScript name (`HiraginoSans-W6`, as PDF and `.ai` + /// files name fonts). Case and anything but letters and digits are ignored. A name nothing + /// answers to comes back as it is. + pub fn canonical(&self, family: &str, style: &str) -> (String, String) { + if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) { + return (family.to_string(), style.to_string()); + } + let key = norm(family); + let mut found: Vec = + self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect(); + if let Some(v) = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key) { + found.extend(v.iter().cloned()); + } + let ns = norm(style); + // A family name keeps the style asked for; a legacy pair names its face; a PostScript name + // its face whatever style is asked for. + if let Some(a) = found.iter().find(|a| a.style.is_none()) { + return (a.family.clone(), style.to_string()); + } + if let Some(a) = found.iter().find(|a| a.paired.as_ref().is_some_and(|p| *p == ns) || a.paired.is_none()) { + return (a.family.clone(), a.style.clone().unwrap_or_else(|| style.to_string())); + } + match found.first() { + Some(a) => (a.family.clone(), style.to_string()), + None => (family.to_string(), style.to_string()), + } + } + + /// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how + /// it matched. + pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc, FontMatch)> { + let (family, style) = self.canonical(family, style); + let f = self.face(&family, &style)?; + let m = if !f.family.eq_ignore_ascii_case(&family) { + FontMatch::Missing + } else if norm(&f.style) == norm(&style) || f.keys.styles.contains(&norm(&style)) { + FontMatch::Exact + } else { + FontMatch::Style + }; + Some((f, m)) + } + + /// The installed face whose PostScript name is `name` (`HiraginoSans-W3`; any case), loaded + /// from the system fonts if need be. `None` when no installed font has the name. + pub fn find_postscript(&self, name: &str) -> Option> { + let key = norm(name); + if key.is_empty() { + return None; + } + let loaded = |db: &FontDb| db.read_faces().iter().find(|f| f.keys.postscript.contains(&key)).cloned(); + if let Some(f) = loaded(self) { + return Some(f); + } + self.ensure_catalog(); + let face = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key).and_then(|v| { + v.iter().find(|a| a.paired.is_none() && a.style.is_some()).map(|a| (a.family.clone(), a.style.clone().unwrap_or_default())) + }); + let (family, style) = face?; + self.face(&family, &style)?; + loaded(self) + } + + /// The (family, style) of the installed face whose PostScript name is `name` (ignoring ASCII + /// case). Documents name fonts this way, and a family can hold hyphens + /// ("Rounded-X-Mplus-1c-black" is Rounded-X M+ 1c, black), so the name can't be split. + pub fn by_postscript_name(&self, name: &str) -> Option<(String, String)> { + self.ensure_catalog(); + self.postscript.read().unwrap_or_else(|e| e.into_inner()).get(&norm(name)).cloned() + } + /// Load the files of the installed `family`. Returns whether any face was added. #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { @@ -600,17 +819,21 @@ impl FontDb { /// only if no font at all is loaded (the bundled fonts failed to parse), in which case text has /// no glyphs. pub fn face(&self, family: &str, style: &str) -> Option> { + let (family, style) = self.canonical(family, style); + let (family, style) = (family.as_str(), style.as_str()); let found = self.find(family, style); if found.as_ref().is_some_and(|f| norm(&f.style) == norm(style)) { return found; } - // The family, or this style of it, is installed but not loaded yet. + // The family, or this style of it, is installed but not loaded yet. Looked for again + // whether or not this call loaded it: another thread may have just done so (then this + // load adds nothing, but the face is there). #[cfg(not(target_arch = "wasm32"))] - if (found.is_none() || self.is_cataloged(family, style)) - && self.load_cataloged(family) - && let Some(f) = self.find(family, style) - { - return Some(f); + if found.is_none() || self.is_cataloged(family, style) { + self.load_cataloged(family); + if let Some(f) = self.find(family, style) { + return Some(f); + } } found .or_else(|| self.find(FALLBACK_FAMILY, style)) @@ -638,7 +861,8 @@ impl FontDb { /// Is `family` available (loaded, or installed on the system)? pub fn has_family(&self, family: &str) -> bool { - self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) + let (family, _) = self.canonical(family, ""); + self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) } pub(crate) fn is_loaded(&self, family: &str) -> bool { @@ -771,3 +995,114 @@ impl OutlinePen for FlipPen { self.0.close_path(); } } + +#[cfg(test)] +mod alias_tests { + use super::*; + + /// A one-table font holding a name table from (platform, language, name id, string) records: + /// Windows Unicode (UTF-16) or Mac Roman (ASCII here). + fn name_font(records: &[(u16, u16, u16, &str)]) -> Vec { + let (mut head, mut strings) = (vec![], vec![]); + let n = records.len() as u16; + for v in [0, n, 6 + 12 * n] { + head.extend_from_slice(&v.to_be_bytes()); + } + for &(platform, lang, id, s) in records { + let bytes: Vec = if platform == 3 { s.encode_utf16().flat_map(u16::to_be_bytes).collect() } else { s.as_bytes().to_vec() }; + let encoding = if platform == 3 { 1 } else { 0 }; + for v in [platform, encoding, lang, id, bytes.len() as u16, strings.len() as u16] { + head.extend_from_slice(&v.to_be_bytes()); + } + strings.extend(bytes); + } + head.extend(strings); + let mut font = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + font.extend_from_slice(b"name"); + for v in [0u32, 28, head.len() as u32] { + font.extend_from_slice(&v.to_be_bytes()); + } + font.extend(head); + font + } + + fn keys(records: &[(u16, u16, u16, &str)]) -> (String, String, FaceKeys) { + let font = name_font(records); + let f = skrifa::FontRef::new(&font).unwrap(); + let (family, style) = face_names(&f).unwrap(); + let k = FaceKeys::of(&f, &family, &style); + (family, style, k) + } + + const EN: u16 = 0x0409; + const JA: u16 = 0x0411; + + /// Laid out like the name table of Hiragino Sans W3. + const HIRAGINO_W3: &[(u16, u16, u16, &str)] = &[ + (1, 0, 1, "Hiragino Sans"), + (1, 0, 2, "W3"), + (3, EN, 1, "Hiragino Sans W3"), + (3, EN, 2, "Regular"), + (3, JA, 1, "ヒラギノ角ゴシック W3"), + (3, JA, 2, "Regular"), + (3, EN, 6, "HiraginoSans-W3"), + (3, EN, 16, "Hiragino Sans"), + (3, JA, 16, "ヒラギノ角ゴシック"), + (3, EN, 17, "W3"), + (3, JA, 17, "W3"), + ]; + + #[test] + fn a_face_answers_to_its_names_in_every_language() { + let (family, style, k) = keys(HIRAGINO_W3); + assert_eq!((family.as_str(), style.as_str()), ("Hiragino Sans", "W3")); + let aliases = Alias::of(&family, &style, &k); + let find = |name: &str| aliases.iter().filter(|(n, _)| *n == norm(name)).map(|(_, a)| a.clone()).collect::>(); + assert!(find("ヒラギノ 角ゴシック").iter().any(|a| a.family == "Hiragino Sans" && a.style.is_none()), "a family name in Japanese"); + let legacy = find("Hiragino Sans W3"); + assert!(legacy.iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.as_deref() == Some("regular")), "{legacy:?}"); + assert!(!legacy.iter().any(|a| a.paired.as_deref() == Some("w3")), "Mac family + Windows style is no pair"); + assert!(find("HiraginoSans-W3").iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.is_none()), "the PostScript name"); + } + + #[test] + fn without_typographic_names_the_legacy_ones_are_the_familys() { + let (family, style, k) = keys(&[(3, EN, 1, "Example Serif"), (3, EN, 2, "Bold Italic"), (3, JA, 1, "例セリフ"), (3, JA, 2, "太字斜体")]); + assert_eq!((family.as_str(), style.as_str()), ("Example Serif", "Bold Italic")); + assert!(k.families.contains(&norm("例セリフ")) && k.styles.contains(&norm("太字斜体")) && k.legacy.is_empty()); + } + + #[test] + fn resolve_says_how_a_font_matched() { + let db = FontDb::with_font_dirs(vec![]); + let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.family.clone(), face.style.clone(), m)).unwrap(); + assert_eq!(m("Source Sans 3", "Semibold"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact)); + assert_eq!(m("Source Sans 3", "Black").2, FontMatch::Style); + assert_eq!(m("No Such Family", "Regular"), (FALLBACK_FAMILY.into(), "Regular".into(), FontMatch::Missing)); + // A PostScript name names its face, whatever style is asked for (text opened from a PDF). + assert_eq!(m("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact)); + let semi = db.find_postscript("sourcesans3-semibold").unwrap(); + assert_eq!(semi.style, "Semibold"); + assert!(db.find_postscript("NoSuchFont-Regular").is_none()); + assert!(db.has_family("SourceSans3-Regular")); + } +} + +#[cfg(test)] +mod race_tests { + use super::*; + + #[test] + fn a_family_loaded_by_another_thread_meanwhile_is_still_found() { + // Two threads ask for the same installed family at once: each must get it, not the + // fallback (the second one's load finds every face already added). + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts"); + let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir]))); + // Only the catalog knows Inter (the bundled faces are dropped). + db.faces.write().unwrap().retain(|f| f.family != "Inter"); + let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(move || db.face("Inter", "Regular").map(|f| f.family.clone()))).collect(); + for t in threads { + assert_eq!(t.join().unwrap().as_deref(), Some("Inter")); + } + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index c1f9e307..d2bea973 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -10,6 +10,7 @@ #![forbid(unsafe_code)] mod composer; +pub mod craft_fonts; pub mod edit; pub mod embed; mod features; @@ -19,8 +20,9 @@ mod layout; mod shape; pub mod thread; +pub use craft_fonts::{CRAFT_FONTS, CraftFont}; pub use features::OtFeatures; -pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, SHIPPORI_MINCHO_REGULAR, style_weight, system_font_dirs}; +pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, style_weight, system_font_dirs}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 28dfd379..dde4c2a9 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -422,8 +422,47 @@ fn tab_stops_position_text() { assert!((x_of(&p, 4) - 72.0).abs() < 1e-6); } +/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when +/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts. +fn japanese_fonts() -> bool { + let built_in = CRAFT_FONTS.iter().any(|f| f.is_japanese()); + if !built_in { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + built_in +} + +#[test] +fn japanese_text_uses_the_craft_fonts_mincho_after_the_bundled_fonts() { + if !japanese_fonts() { + return; + } + let l = layout(db(), &point("日本語の文字", style(20.0))); + assert_eq!(l.glyphs.len(), 6); + assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()), "real glyphs, no tofu"); + let face = db().face_covering('日').unwrap(); + assert!(face.family.contains("Mincho"), "document text falls back to a Mincho face: {}", face.family); + assert!(CRAFT_FONTS.iter().any(|f| f.family == face.family)); + // Latin keeps the bundled fallback family. + assert_eq!(db().face_covering('a').unwrap().family, FALLBACK_FAMILY); +} + +#[test] +fn japanese_text_lays_out_without_the_craft_fonts() { + // Built either way, Japanese text lays out (as missing glyphs when no font has them). + let l = layout(db(), &point("日本語の文字 abc", style(20.0))); + assert_eq!(l.lines.len(), 1); + assert!(l.glyphs.iter().all(|g| g.advance.is_finite())); + if CRAFT_FONTS.is_empty() { + assert!(db().families().iter().all(|f| !f.contains("Mincho") && !f.contains("Gothic")), "no Japanese font is bundled"); + } +} + #[test] fn vertical_japanese_columns_have_upright_ink_and_edit_geometry() { + if !japanese_fonts() { + return; + } let mut t = point("日本語\n縦書き", style(30.0)); t.vertical = true; let l = layout(db(), &t); diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index 5b9c96e1..530ebc93 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -142,3 +142,15 @@ fn installed_styles_of_a_loaded_family_load_when_asked_for() { // A style nobody has still gets the closest one. assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold"); } + +/// Documents name fonts by PostScript name, and families can hold hyphens: the installed face of +/// that exact name gives the family and style, however the name splits. +#[test] +fn installed_faces_are_found_by_postscript_name() { + let dir = font_dir("postscript"); + let db = FontDb::with_font_dirs(vec![dir]); + // The renamed Source Sans 3 files keep their PostScript names. + assert_eq!(db.by_postscript_name("SourceSans3-Bold"), Some((FAMILY.to_string(), "Bold".to_string()))); + assert_eq!(db.by_postscript_name("sourcesans3-regular"), Some((FAMILY.to_string(), "Regular".to_string())), "any case"); + assert_eq!(db.by_postscript_name("Rounded-X-Mplus-1c-black"), None); +} diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 3a27c581..863dd101 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -243,9 +243,15 @@ pub fn hatch(path: &BezPath, gap: f64) -> BezPath { struct RegionCache { key: Option<(usize, Vec)>, regions: Vec<(po::Region, BezPath, Rect)>, + /// Live Paint faces (every path an edge, open ones included) rather than Shape Builder + /// regions (the filled areas of the shapes). + live: bool, } impl RegionCache { + fn live_paint() -> Self { + Self { live: true, ..Default::default() } + } fn get(&mut self, cx: &ToolContext) -> &[(po::Region, BezPath, Rect)] { let key = (cx.doc as *const Document as usize, cx.selection.objects.clone()); if self.key.as_ref() != Some(&key) { @@ -254,7 +260,8 @@ impl RegionCache { self.regions = if shapes.is_empty() { vec![] } else { - po::regions(&shapes) + let regions = if self.live { po::live_paint(&shapes).0 } else { po::regions(&shapes) }; + regions .into_iter() .filter_map(|r| { let bp = r.path.to_bezpath(); @@ -437,12 +444,17 @@ fn lp_overlays(h: &LpHover, cache: &RegionCache) -> Vec { /// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click /// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a /// Live Paint group first. -#[derive(Default)] pub struct LivePaintBucketTool { cache: RegionCache, hover: LpHover, } +impl Default for LivePaintBucketTool { + fn default() -> Self { + Self { cache: RegionCache::live_paint(), hover: LpHover::None } + } +} + impl Tool for LivePaintBucketTool { fn id(&self) -> &'static str { "livePaintBucket" @@ -500,12 +512,17 @@ impl Tool for LivePaintBucketTool { // ---------- Live Paint Selection ---------- /// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. -#[derive(Default)] pub struct LivePaintSelectionTool { cache: RegionCache, hover: LpHover, } +impl Default for LivePaintSelectionTool { + fn default() -> Self { + Self { cache: RegionCache::live_paint(), hover: LpHover::None } + } +} + impl Tool for LivePaintSelectionTool { fn id(&self) -> &'static str { "livePaintSelection" diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 4e30ed00..15faeb89 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -385,6 +385,21 @@ pub trait Tool: Send { fn text_input(&mut self, _cx: &ToolContext, _s: &str) -> Vec { vec![] } + /// IME composition (marked text) for the tool that wants text: `text` replaces the previous + /// marked text (or the selection); an empty `text` ends the composition. `active_chars` is the + /// clause being converted, in characters of `text`. The committed result arrives through + /// [`Tool::text_input`]. + fn ime_preedit(&mut self, _cx: &ToolContext, _text: &str, _active_chars: Option>) -> Vec { + vec![] + } + /// Is uncommitted IME text being shown? Keys, shortcuts and Undo wait while it is. + fn composing(&self) -> bool { + false + } + /// Where the IME candidate window goes: the caret line (top, bottom) in document space. + fn ime_caret(&self, _cx: &ToolContext) -> Option<(Point, Point)> { + None + } fn notify(&mut self, _cx: &ToolContext, _what: &str) {} /// Called when the user switches away (finish pending work). fn deactivate(&mut self, _cx: &ToolContext) -> Vec { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 872f0313..2cc3d09e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -21,6 +21,9 @@ enum State { Moving { start: Point, began: bool, + /// Shift-pressed on this selected object: released without a drag, it leaves the + /// selection. + deselect: Option, }, Scaling { handle: Handle, @@ -141,16 +144,19 @@ impl Tool for SelectionTool { Some(h) => { let top = h.top_object(cx.isolation); let mut out = vec![]; + let mut deselect = None; if m.shift { - out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 }))); + // A Shift-click takes a selected object out of the selection when it + // is released; a Shift-drag moves the selection, constrained. if cx.selection.contains(top) { - self.state = State::Idle; - return out; + deselect = Some(top); + } else { + out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 }))); } } else if !cx.selection.contains(top) { out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] }))); } - self.state = State::Moving { start: p, began: false }; + self.state = State::Moving { start: p, began: false, deselect }; out } None => { @@ -185,7 +191,7 @@ impl Tool for SelectionTool { d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0); self.guides.clear(); } - self.state = State::Moving { start, began: true }; + self.state = State::Moving { start, began: true, deselect: None }; self.measure = Some((p, cx.offset_label(d.x, d.y))); out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); out @@ -215,12 +221,16 @@ impl Tool for SelectionTool { self.state = State::Idle; c.finish() } - (PointerKind::Up, State::Moving { began, .. }) => { + (PointerKind::Up, State::Moving { began, deselect, .. }) => { self.state = State::Idle; self.measure = None; self.guides.clear(); self.targets = None; - if began { vec![Action::Commit] } else { vec![] } + match (began, deselect) { + (true, _) => vec![Action::Commit], + (false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + (false, None) => vec![], + } } (PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => { self.state = State::Idle; @@ -339,6 +349,48 @@ mod tests { assert_eq!(a, vec![Action::Commit]); } + #[test] + fn shift_drag_on_a_selected_object_moves_it_constrained() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); + // Pressing keeps the selection: nothing is toggled yet. + assert!(t.pointer(&cx, &shift(PointerKind::Down, 150.0, 150.0)).is_empty()); + let a = t.pointer(&cx, &shift(PointerKind::Drag, 190.0, 160.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + assert!( + matches!(&a[1], Action::Preview(c, v) if c == "object.transform" + && v["matrix"][4].as_f64().is_some_and(|x| x > 39.0) && v["matrix"][5] == 0.0), + "moved right, constrained to horizontal: {a:?}" + ); + assert_eq!(t.pointer(&cx, &shift(PointerKind::Up, 190.0, 160.0)), vec![Action::Commit]); + } + + #[test] + fn shift_click_toggles_an_object_on_release() { + let (d, id) = doc_with_rect(); + let p = paint(); + let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); + let toggle = vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]; + // Selected: it leaves the selection when the click is released. + let mut s = Selection::default(); + s.add(id); + let cx1 = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx1, &shift(PointerKind::Down, 150.0, 150.0)).is_empty()); + assert_eq!(t.pointer(&cx1, &shift(PointerKind::Up, 150.0, 150.0)), toggle); + // Not selected: it joins the selection at once, ready to be dragged. + let s = Selection::default(); + let cx2 = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 150.0, 150.0)), toggle); + assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty()); + } + #[test] fn marquee_selects() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index e97ae4bd..a152b388 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -12,7 +12,11 @@ //! `text.editRange` (the changed span, with its styled runs) against the session snapshot. //! Styling a selected range goes through `text.setRangeStyle` (the Character panel reads the //! selection from [`Tool::options`]). +//! +//! IME: the marked text of a composition is part of the typing session (so it lays out in place), +//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it. +use std::ops::Range; use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; @@ -38,6 +42,15 @@ struct Typing { cur: Vec, } +/// IME marked text inside the typing session, not yet committed. +#[derive(Clone, Debug, PartialEq)] +struct Preedit { + /// Byte range of the marked text in the session's runs. + range: Range, + /// The clause being converted, in bytes relative to `range.start` (empty: the IME's cursor). + active: Option>, +} + #[derive(Default)] pub struct TypeTool { vertical: bool, @@ -52,6 +65,7 @@ pub struct TypeTool { /// Horizontal position kept by Up/Down (text space). goal_x: Option, typing: Option, + preedit: Option, press: Option, drag: Option, /// The press landed in the edited text: dragging selects. @@ -113,8 +127,10 @@ impl TypeTool { (self.caret.min(self.anchor), self.caret.max(self.anchor)) } - /// End the typing session (one undo step). + /// End the typing session (one undo step). Marked text left by an IME stays as typed (as + /// clicking away from a composition does in macOS text views); the UI interrupts the IME. fn commit(&mut self) -> Vec { + self.preedit = None; if self.typing.take().is_some() { vec![Action::Commit] } else { vec![] } } @@ -187,6 +203,7 @@ impl TypeTool { self.anchor = caret; self.goal_x = None; self.typing = None; + self.preedit = None; } /// Byte under `p` in the edited text if `p` is inside it (frame, path band or layout bounds). @@ -277,6 +294,14 @@ impl TypeTool { } } +/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). +/// `None` when `r` runs past the end of `s` or backwards. +fn char_range_to_bytes(s: &str, r: Range) -> Option> { + let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) }; + let (a, b) = (byte(r.start)?, byte(r.end)?); + (a <= b).then_some(a..b) +} + /// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping). fn common_affixes(a: &str, b: &str) -> (usize, usize) { let mut p = 0; @@ -371,6 +396,15 @@ impl Tool for TypeTool { return vec![]; } let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect(); + // An IME commit replaces its marked text (an empty commit just removes it). + let committing = self.preedit.is_some(); + if committing && s.is_empty() { + return self.ime_preedit(cx, "", None); + } + if let Some(p) = self.preedit.take() { + self.anchor = p.range.start; + self.caret = p.range.end; + } if s.is_empty() { return vec![]; } @@ -387,13 +421,17 @@ impl Tool for TypeTool { self.clicks = (None, 0); // Pasting what we copied keeps its formatting (unless pasted text is kept plain). let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); - if s.len() > 1 && s == clip && !cx.paste_plain_text { + if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text { let runs = self.clipboard.clone(); return self.replace(cx, "", Some(runs)); } self.replace(cx, &s, None) } fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + // The IME owns the keys while it composes (the UI doesn't send them; this is a guard). + if self.preedit.is_some() { + return vec![]; + } let Some(t) = self.current(cx) else { self.editing = None; return vec![]; @@ -485,6 +523,51 @@ impl Tool for TypeTool { _ => vec![], } } + fn ime_preedit(&mut self, cx: &ToolContext, text: &str, active_chars: Option>) -> Vec { + if self.editing.is_none() { + return vec![]; + } + // A clear with nothing marked (some integrations send one when the IME is enabled) must + // not delete the selection. + if text.is_empty() && self.preedit.is_none() { + return vec![]; + } + let text: String = text.chars().filter(|c| !c.is_control()).collect(); + // The new marked text replaces the previous one, or the selection when it starts. + if let Some(p) = self.preedit.take() { + self.anchor = p.range.start; + self.caret = p.range.end; + } + self.clicks = (None, 0); + let mut out = self.replace(cx, &text, None); + if text.is_empty() { + // Cancelled before anything else was typed: no empty undo step. + if self.typing.as_ref().is_some_and(|ty| ty.cur == ty.base) { + self.typing = None; + out.push(Action::Cancel); + } + return out; + } + let end = self.caret; + let start = end.saturating_sub(text.len()); + self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) }); + out + } + fn composing(&self) -> bool { + self.preedit.is_some() + } + fn ime_caret(&self, cx: &ToolContext) -> Option<(Point, Point)> { + let t = self.current(cx)?; + let lay = self.layout(&t); + let len = t.plain_text().len(); + // The candidate window follows the clause being converted, else the caret. + let at = match &self.preedit { + Some(p) => p.range.start + p.active.as_ref().map_or(0, |r| r.start), + None => self.caret.min(self.anchor), + }; + let (a, b) = vectorcraft_text::caret_position(&lay, at.min(len)); + Some((t.xf * a, t.xf * b)) + } fn deactivate(&mut self, cx: &ToolContext) -> Vec { self.finish(cx) } @@ -510,13 +593,14 @@ impl Tool for TypeTool { self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id); } } - /// `{editing, start, end, caret, anchor, typing}` — the Character panel styles `start..end`. + /// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles + /// `start..end`. fn options(&self) -> Value { match self.editing { Some(id) => { // Offsets may exceed the text after Select All; callers clamp to the text length. let (a, b) = self.sel(); - json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some()}) + json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some(), "composing": self.preedit.is_some()}) } None => json!({"editing": null}), } @@ -525,7 +609,10 @@ impl Tool for TypeTool { /// `commitTyping` (the caller commits the engine interaction). fn set_option(&mut self, key: &str, v: &Value) { match key { - "commitTyping" | "endTyping" => self.typing = None, + "commitTyping" | "endTyping" => { + self.typing = None; + self.preedit = None; + } "selectAll" if self.editing.is_some() => { self.anchor = 0; self.caret = usize::MAX / 4; @@ -544,6 +631,7 @@ impl Tool for TypeTool { } "stopEditing" => { self.typing = None; + self.preedit = None; self.editing = None; } _ => {} @@ -578,7 +666,24 @@ impl Tool for TypeTool { o.push(Overlay::Path { path: f, color: [79, 128, 255], width: 1.0, dashed: false }); } let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)); - if a == b { + if let Some(pe) = &self.preedit { + // Marked text: a thin underline, a thick one under the clause being converted. + let under = |o: &mut Vec, r: Range, width: f32| { + for q in vectorcraft_text::selection_quads(&lay, r.start.min(len), r.end.min(len)) { + let (l, rr) = (t.xf * q[3], t.xf * q[2]); + o.push(Overlay::Path { path: vectorcraft_geom::Line::new(l, rr).to_path(0.1), color: [0, 0, 0], width, dashed: false }); + } + }; + under(&mut o, pe.range.clone(), 1.0); + match &pe.active { + Some(r) if !r.is_empty() => under(&mut o, pe.range.start + r.start..pe.range.start + r.end, 2.5), + _ => { + let at = pe.range.start + pe.active.as_ref().map_or(pe.range.len(), |r| r.start); + let (p, q) = vectorcraft_text::caret_position(&lay, at.min(len)); + o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false }); + } + } + } else if a == b { let (p, q) = vectorcraft_text::caret_position(&lay, self.caret.min(len)); o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false }); } else { diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index 332a3b8f..8188f40c 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -228,6 +228,52 @@ fn vertical_tools_preserve_their_ids_and_create_vertical_text() { } } +#[test] +fn ime_ranges_convert_characters_to_bytes() { + assert_eq!(char_range_to_bytes("ががく", 1..3), Some(3..9)); + assert_eq!(char_range_to_bytes("ががく", 3..3), Some(9..9)); + assert_eq!(char_range_to_bytes("a雅b", 1..2), Some(1..4)); + assert_eq!(char_range_to_bytes("ががく", 2..4), None, "past the end"); + #[allow(clippy::reversed_empty_ranges)] + let backwards = 2..1; + assert_eq!(char_range_to_bytes("ががく", backwards), None, "backwards"); +} + +#[test] +fn marked_text_is_underlined_with_the_converting_clause_thick() { + let (mut d, id, mut tool) = editing("曲"); + let snap = d.clone(); + let (sel, p) = (Selection::default(), paint()); + tool.caret = 3; + tool.anchor = 3; + let acts = tool.ime_preedit(&cx(&d, &sel, &p), "雅楽演奏", Some(2..4)); + apply(&mut d, &snap, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(t.plain_text(), "曲雅楽演奏"); + assert_eq!(tool.preedit, Some(Preedit { range: 3..15, active: Some(6..12) })); + let widths: Vec = tool + .overlays(&cx(&d, &sel, &p)) + .iter() + .filter_map(|o| match o { + Overlay::Path { width, .. } => Some(*width), + _ => None, + }) + .collect(); + assert_eq!(widths, vec![1.0, 2.5], "a thin underline, then the thick one under 演奏"); + // The candidate window follows the converting clause. + let (top, _) = tool.ime_caret(&cx(&d, &sel, &p)).unwrap(); + let lay = vectorcraft_text::layout(FontDb::global(), t); + let (at, _) = vectorcraft_text::caret_position(&lay, 9); + assert!((top - t.xf * at).hypot() < 1e-6); + // Keys wait for the IME; a commit replaces the marked text. + assert!(key(&mut tool, &d, ToolKey::Backspace, Mods::default()).is_empty()); + let acts = tool.text_input(&cx(&d, &sel, &p), "雅楽演奏会"); + apply(&mut d, &snap, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(t.plain_text(), "曲雅楽演奏会"); + assert!(!tool.composing()); +} + #[test] fn arrows_follow_the_columns_of_vertical_type() { let (mut d, id) = doc_with_text("§§§\n§§§"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ca328b03..842e65ad 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -158,10 +158,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { fit(app, "view.fitArtboard"); } let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag()); - handle_input(app, ui, &resp, rect); + // A press on the canvas while the context menu is open only closes it (a drag too, which egui + // alone would leave open); the tool doesn't get it. + if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { + egui::Popup::close_all(ui.ctx()); + } else { + handle_input(app, ui, &resp, rect); + } let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); panel_drop(app, ui, &resp, &xf); + context_menu(app, &resp, &xf); let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -330,6 +337,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let overlays = app.session.overlays(view_info); draw_overlays(&painter, &xf, &overlays, &t); } + ime_output(app, ui.ctx(), &xf); crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect); if app.ui.view.rulers && app.ui.screen_mode < 3 { @@ -821,6 +829,25 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option