mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 13:06:56 -06:00
Compare commits
87
Commits
@@ -23,12 +23,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-15]
|
||||
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
||||
# release.yml builds macOS (a failing release is fixed and rebuilt).
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
@@ -39,11 +41,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: ci-${{ matrix.os }}
|
||||
- name: cargo xtask ci
|
||||
|
||||
@@ -23,34 +23,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
@@ -19,39 +19,5 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/deckcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/deckcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.deckcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.deckcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.deckcraft.yml')); assert m['id'] == 'ai.storyteller.deckcraft' and m['command'] == 'deckcraft'"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
+23
-414
@@ -1,14 +1,3 @@
|
||||
# Release pipeline: every push to `release` builds installers for every platform and creates or
|
||||
# updates the draft GitHub Release "DeckCraft v<version>" (tag v<version>). Recipe:
|
||||
# craftrules release/playbook.md.
|
||||
#
|
||||
# The version has one source: `[workspace.package] version` in Cargo.toml (`cargo xtask version`,
|
||||
# `cargo xtask version set X.Y.Z`). The app shows it in About and `--version`; the release name
|
||||
# and tag use it too. workflow_dispatch can override it for a test build (applied to the build).
|
||||
#
|
||||
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
|
||||
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
|
||||
# warning, never a failed build.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -22,413 +11,33 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
DECKCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# Optional font input (storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
# This commit adds fonts/latin-manifest.txt (the Latin presentation fonts).
|
||||
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
# Only the release branch builds (a manual dispatch from any other ref skips everything). The
|
||||
# branch itself is writable only by release-managers and org admins (ruleset "release").
|
||||
if: github.ref == 'refs/heads/release'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/common.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### DeckCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
environment: release
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10).
|
||||
runs-on: ${{ matrix.runner }}
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_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: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/deckcraft$|metainfo|applications'
|
||||
ldd target/release/deckcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak-sources:
|
||||
name: Flatpak crate sources
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Vendor the crate sources (flatpak builds run offline)
|
||||
run: |
|
||||
python3 -m venv "$RUNNER_TEMP/venv"
|
||||
"$RUNNER_TEMP/venv/bin/pip" install --quiet aiohttp tomlkit
|
||||
curl -fsSL -o "$RUNNER_TEMP/flatpak-cargo-generator.py" \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
"$RUNNER_TEMP/venv/bin/python" "$RUNNER_TEMP/flatpak-cargo-generator.py" Cargo.lock -o cargo-sources.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flatpak-cargo-sources
|
||||
path: cargo-sources.json
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Linux Flatpak
|
||||
needs: [version, flatpak-sources]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# Best effort: Flathub builds from source itself (playbook §7); the bundle is a convenience.
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
||||
options: --privileged
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: |
|
||||
# No cargo in this container: the same edit `cargo xtask version set` makes.
|
||||
sed -i "0,/^version = \".*\"/s//version = \"$DECKCRAFT_VERSION\"/" Cargo.toml
|
||||
grep -m1 '^version' Cargo.toml
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: flatpak-cargo-sources
|
||||
path: packaging/linux/flatpak
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
with:
|
||||
manifest-path: packaging/linux/flatpak/ai.storyteller.deckcraft.yml
|
||||
bundle: deckcraft-${{ needs.version.outputs.version }}-linux-x86_64.flatpak
|
||||
upload-artifact: false
|
||||
cache-key: flatpak-builder-${{ github.sha }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak
|
||||
path: deckcraft-*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
VERSION_OVERRIDE: ${{ inputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
envs: DECKCRAFT_VERSION DECKCRAFT_BUILD_DATE DECKCRAFT_BUILD_SHA VERSION_OVERRIDE
|
||||
# Copies the workspace (with dist/release) back; the target dir lives outside it so the
|
||||
# copy stays small.
|
||||
copyback: true
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "*"
|
||||
export CARGO_TARGET_DIR="$HOME/target" CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
if [ -n "$VERSION_OVERRIDE" ]; then cargo xtask version set "$DECKCRAFT_VERSION"; fi
|
||||
bash packaging/freebsd/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_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:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
# The installers are required; the Flatpak bundle and FreeBSD tarball are attached when they
|
||||
# built.
|
||||
if: >-
|
||||
!cancelled() && needs.version.result == 'success' && needs.macos.result == 'success' &&
|
||||
needs.windows.result == 'success' && needs.linux.result == 'success' && needs.web.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="DeckCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-title: DeckCraft
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft
|
||||
PowerPoint parity — and superiority (speed, openness, agent control). It runs natively on macOS,
|
||||
Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions:
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PrintCraft, EffectCraft, DesignCraft
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
|
||||
(`../<app>`).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
|
||||
@@ -158,5 +158,36 @@ to `main`.
|
||||
## 7. Planning
|
||||
|
||||
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. `ROADMAP.md` (committed)
|
||||
tracks status, milestones, parity and estimates; update it whenever a milestone task lands.
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
|
||||
docs follow craftrules `standards/progress-docs.md`:
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `ROADMAP.md` | Stage, headline numbers, dimensions, features, languages, progress log |
|
||||
| `docs/target-app-parity.md` | The parity assessment against PowerPoint: percentages, hours, calibration |
|
||||
| `docs/gaps.md` | Every known shortfall, ranked: the work list. Pick from the top |
|
||||
| `docs/roadmap.md` | Current focus, the beta plan, milestones |
|
||||
| `docs/parity-checklist.md` | Feature rows scored D/P/M; `cargo xtask parity` regenerates its summary |
|
||||
| `docs/file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`, `animation-parity.md`, `slideshow-parity.md` | Per-dimension and per-feature detail |
|
||||
| `docs/architecture.md` | How DeckCraft is built today |
|
||||
|
||||
When a task lands, re-score its checklist row, delete or update its `gaps.md` entry, add a line to
|
||||
the `ROADMAP.md` progress log, and update each touched doc's status line and revision history.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
@@ -18,12 +18,19 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
|
||||
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
| `assets/app-icon/deckcraft.icns`, `assets/app-icon/deckcraft.ico` | DeckCraft contributors | generated from the PNGs above by `packaging/icons.sh` (sips/iconutil, `cargo xtask ico`) | MIT OR Apache-2.0 | macOS and Windows app icons |
|
||||
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.deckcraft.svg` | DeckCraft contributors | the shapes of `assets/app-icon/icon-source.deckcraft` written out as SVG (same coordinates, colours and corner radii) | MIT OR Apache-2.0 | Scalable app icon for Linux desktops (hicolor theme) |
|
||||
| `assets/app-icon/icon-source.deckcraft` | DeckCraft contributors | made with `deckcraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
|
||||
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
|
||||
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
|
||||
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
|
||||
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
|
||||
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
|
||||
|
||||
## Bundled through dependencies
|
||||
|
||||
|
||||
@@ -13,5 +13,5 @@ win over anything here.
|
||||
- Look at UI changes: `deckcraft --sample --control 7990` + `ui.screenshot`, or
|
||||
`deckcraft-cli render`.
|
||||
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
|
||||
- Keep `ROADMAP.md` and `plan/STATUS.md` current.
|
||||
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
|
||||
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
|
||||
|
||||
Generated
+299
-27
@@ -644,6 +644,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -866,7 +877,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -885,7 +896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -894,7 +905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -903,14 +914,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -919,14 +930,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -948,7 +959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -960,7 +971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -971,7 +982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -979,7 +990,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -988,7 +999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -997,7 +1008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1005,11 +1016,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1019,7 +1030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1035,28 +1046,37 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
@@ -1071,7 +1091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1087,35 +1107,39 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-geom",
|
||||
"deckcraft-metafile",
|
||||
"deckcraft-model",
|
||||
"deckcraft-text",
|
||||
"image",
|
||||
"kurbo",
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
"vello_common 0.2.0",
|
||||
"vello_cpu 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
"unicode-bidi",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1132,11 +1156,12 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1145,7 +1170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1202,6 +1227,17 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
@@ -1335,6 +1371,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -1633,6 +1670,15 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -2047,6 +2093,134 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -2348,6 +2522,12 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
@@ -3172,6 +3352,15 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -4196,6 +4385,16 @@ dependencies = [
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
@@ -4383,6 +4582,24 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.27.0"
|
||||
@@ -4699,6 +4916,22 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webbrowser"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
|
||||
dependencies = [
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"url",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -5356,6 +5589,12 @@ dependencies = [
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -5427,7 +5666,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5602,6 +5841,39 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "3.0.0"
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/deckcraft"
|
||||
# internal
|
||||
deckcraft-geom = { path = "crates/geom" }
|
||||
deckcraft-color = { path = "crates/color" }
|
||||
deckcraft-metafile = { path = "crates/metafile" }
|
||||
deckcraft-model = { path = "crates/model" }
|
||||
deckcraft-fonts = { path = "crates/fonts" }
|
||||
deckcraft-text = { path = "crates/text" }
|
||||
@@ -48,6 +49,7 @@ rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
unicode-segmentation = "1"
|
||||
unicode-linebreak = "0.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
|
||||
@@ -51,13 +51,14 @@
|
||||
<a href="#getting-started">Getting started</a> ·
|
||||
<a href="#agents-cli-and-mcp">Agents, CLI and MCP</a> ·
|
||||
<a href="#roadmap">Roadmap</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">The Crafting Apps</a> ·
|
||||
<a href="#license-and-credits">License</a>
|
||||
</p>
|
||||
|
||||
## What works today
|
||||
|
||||
DeckCraft is in early development, heading for its first alpha (see the [roadmap](ROADMAP.md)).
|
||||
DeckCraft is in alpha, heading for beta (see the [roadmap](ROADMAP.md)).
|
||||
Today it can:
|
||||
|
||||
- **Make decks**: slide masters and eleven layouts with placeholders that inherit position and
|
||||
@@ -102,10 +103,43 @@ cd deckcraft
|
||||
cargo run --release -p deckcraft-cli -- render --sample --all --scale 1 out/
|
||||
```
|
||||
|
||||
On Linux, the build also needs the ALSA headers for slide-show sound: `alsa-lib-devel` on Fedora,
|
||||
`libasound2-dev` on Debian and Ubuntu, `alsa-lib` on Arch. Without them it stops at `alsa-sys`.
|
||||
|
||||
Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts): clone it next to this repo
|
||||
(`../craft-fonts`) and local builds pick it up automatically; without it DeckCraft uses your system
|
||||
fonts.
|
||||
|
||||
Each [GitHub release](https://github.com/storytold/deckcraft/releases) has ready-made builds, on Linux as
|
||||
an AppImage, a `.deb`, an `.rpm` and a tarball. On Gentoo, the community [::snakebyte
|
||||
overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as
|
||||
`app-office/deckcraft-bin` (not maintained by the DeckCraft team):
|
||||
|
||||
```sh
|
||||
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
|
||||
emaint sync -r snakebyte
|
||||
echo 'app-office/deckcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/deckcraft
|
||||
emerge --ask app-office/deckcraft-bin
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/deckcraft.log` in the
|
||||
settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraft/logs/`, or
|
||||
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
|
||||
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
|
||||
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
|
||||
PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
|
||||
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
|
||||
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DeckCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,deckcraft_pptx=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`deckcraft*=debug`). |
|
||||
|
||||
The logger is `apps/deckcraft/src/logging.rs`; the realtime audio callback does not log. The web
|
||||
app logs to the browser console instead.
|
||||
|
||||
## Agents, CLI and MCP
|
||||
|
||||
Every action in DeckCraft is a command with a stable id, so people, scripts and AI agents use the
|
||||
@@ -120,10 +154,72 @@ deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --con
|
||||
|
||||
## Roadmap
|
||||
|
||||
DeckCraft covers about **79% of PowerPoint's features by breadth** (93% of the core ones) and is
|
||||
roughly **80% of the way to a first alpha**. See [ROADMAP.md](ROADMAP.md) for the alpha checklist,
|
||||
parity estimates and what's next, and [docs/parity.md](docs/parity.md) for the feature-by-feature
|
||||
scorecard.
|
||||
DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core
|
||||
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
|
||||
[docs/gaps.md](docs/gaps.md) for every known shortfall and
|
||||
[docs/parity-checklist.md](docs/parity-checklist.md) for the feature-by-feature checklist.
|
||||
|
||||
## Downloads
|
||||
|
||||
**Download DeckCraft** from GitHub: the [latest release](https://github.com/storytold/deckcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/deckcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `deckcraft-<ver>-windows-x64.msi` | `deckcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `deckcraft-<ver>-windows-arm64.msi` | `deckcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `deckcraft-<ver>-windows-x86.msi` | `deckcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
|
||||
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
|
||||
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
|
||||
PowerShell, from the folder containing the executable:
|
||||
|
||||
```powershell
|
||||
$env:WGPU_BACKEND = "vulkan"
|
||||
& .\deckcraft.exe
|
||||
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
|
||||
```
|
||||
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `deckcraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `deckcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `deckcraft-<ver>-linux-x86_64.AppImage` | `deckcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `deckcraft-<ver>-linux-x86_64.flatpak` | `deckcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `deckcraft-<ver>-linux-x86_64.deb` | `deckcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `deckcraft-<ver>-linux-x86_64.rpm` | `deckcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `deckcraft-<ver>-linux-x86_64.tar.gz` | `deckcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
RISC-V 64 (riscv64): `deckcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (cross-compiled; needs glibc 2.39+, e.g. Ubuntu 24.04+).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `deckcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `deckcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
@@ -137,7 +233,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
|
||||
+136
-82
@@ -1,100 +1,154 @@
|
||||
# DeckCraft roadmap
|
||||
|
||||
DeckCraft aims at full PowerPoint parity — and to be better: faster, open (a documented zip+JSON
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
(macOS, Windows, Linux, BSD and the web).
|
||||
(macOS, Windows, Linux, BSD and the web). This page is the summary; the detail is in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) and the work list in
|
||||
[docs/gaps.md](docs/gaps.md).
|
||||
|
||||
## Status (2026-10-07)
|
||||
## Headline numbers
|
||||
|
||||
**Working today**
|
||||
- Document model: presentations, slides, masters and 11 standard layouts with placeholder inheritance
|
||||
(slide → layout → master → theme), sections, notes, comments, custom shows, header/footer fields,
|
||||
embedded media, unlimited undo built on shared slide snapshots.
|
||||
- Themes: 8 original themes with colour and font schemes, custom colours/fonts, backgrounds,
|
||||
slide size presets.
|
||||
- Shapes: ~150 preset geometries with adjustment handles, text boxes, pictures (with crop and
|
||||
adjustments), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more),
|
||||
basic SmartArt, groups, connectors, ink, action buttons, WordArt.
|
||||
- Text: in-place editing with caret, selection, keyboard and mouse; every Home-tab font and paragraph
|
||||
control; bullets and numbering; levels; autofit; columns; vertical text; fields; hyperlinks.
|
||||
- Editing: select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; z-order;
|
||||
group/ungroup; align/distribute; Format Painter; Selection pane; clipboard incl. images.
|
||||
- Transitions (fade, push, wipe, split, cover, uncover, zoom, morph…) and animations (entrance,
|
||||
emphasis, exit, motion paths, triggers, by-paragraph builds) on a shared timeline engine.
|
||||
- Slide show: full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen, presenter
|
||||
window, rehearse timings, custom shows, Set Up Show; Reading View.
|
||||
- Views: Normal (thumbnails with sections, slide, notes), Outline, Slide Sorter, Notes Page,
|
||||
Slide Master; zoom; grayscale.
|
||||
- Review: comments, accessibility checker, spelling.
|
||||
- Audio and video: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis and Opus audio; H.264,
|
||||
HEVC, VP9 and AV1 video in MP4/MOV/WebM/MKV (pure-Rust decoders); poster frames, trim, fades,
|
||||
volume, loop, rewind, play across slides, hide during show, full screen; an in-place control bar
|
||||
in the editor and `media.play/pause/stop/seek/info/posterFrame` for agents. WMA/WMV are
|
||||
recognised and embedded but not yet playable.
|
||||
- PDF export: slides, notes pages and handouts (1–9 per page) with a selectable real-text layer, hyperlinks and slide bookmarks (`file.export {format: "pdf", layout}`, File › Export…).
|
||||
- UI: PowerPoint-style ribbon with contextual tabs, ~240 original icons, status bar, panes, command
|
||||
palette, light/dark.
|
||||
- Automation: ~200 commands, every one reachable from `deckcraft-cli`, the app's JSON control
|
||||
channel and the MCP server (headless or connected to the running app).
|
||||
- Web: `apps/deckcraft-web` runs the same UI in the browser (trunk; WebGPU with WebGL2 fallback),
|
||||
opening the sample deck; Open/Insert use the browser file picker, Save/Export download.
|
||||
- Release CI: pushes to `release` build a draft GitHub Release (macOS universal dmg, Windows
|
||||
x64/x86 msi + zip, Linux AppImage/deb/rpm/tar.gz + Flatpak, FreeBSD tar.gz, web zip); signing
|
||||
secrets live in the `release` environment. Version: `cargo xtask version`.
|
||||
|
||||
**Next (in order):** first signed release run · PPTX corpus hardening against real-world decks ·
|
||||
Animation Pane and presenter view polish · vector PDF artwork · Format Shape pane depth (3-D,
|
||||
picture/texture options) · edit points · native macOS menu bar · print · Notes/Handout masters ·
|
||||
equations · SVG pictures · WMA/WMV decoding.
|
||||
|
||||
Milestone details live in `plan/execution-plan.md` (M0–M14, local planning notes).
|
||||
|
||||
## How close to an alpha (estimate, 2026-10-07)
|
||||
|
||||
An **alpha** here means: someone can install a signed build on macOS, Windows or Linux, make a real
|
||||
deck from scratch or from a PowerPoint file, present it with transitions, animations and media,
|
||||
save it back to `.pptx`/PDF without losing work, and hit no crashes on the common paths.
|
||||
|
||||
**DeckCraft is about 80% of the way to that alpha — roughly 30–40 wall-clock hours of a single
|
||||
Claude Opus 5.5 agent** (about 12–18 hours with three agents in parallel).
|
||||
|
||||
| Alpha blocker | State | Estimate |
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| First real release run: signing, notarization, installers verified on each OS | Workflows written and linted, never run | 6 h |
|
||||
| PPTX fidelity on a corpus of real decks (import, round-trip, opens without repair) | Verified on generated decks only | 8 h |
|
||||
| Presenter view and Animation Pane polish (timeline, reorder, preview) | Working, rough | 6 h |
|
||||
| Soak and fuzz the editor (random command sequences, big decks, undo/redo) for crashes and slowness | Unit tests and guards only | 5 h |
|
||||
| UI fidelity pass on the most-used ribbon groups and dialogs; first-run experience | Mostly there | 6 h |
|
||||
| Mascot app icon (owner), README/site screenshots, user docs | Placeholder icon | 3 h |
|
||||
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
|
||||
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
|
||||
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
| Essentials user | ~62% | estimated: core features × discounts for launch (×0.90), clarity (×0.95) and opening received files (×0.90), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
| Hours to beta | **~120–210** Opus 5.5 agent hours (~40–70 h with 3–4 agents) | estimated, [docs/roadmap.md](docs/roadmap.md) |
|
||||
| Hours to full parity | **~365–650** Opus 5.5 agent hours | estimated, [docs/target-app-parity.md](docs/target-app-parity.md) |
|
||||
|
||||
Already alpha-ready: editing, text, shapes (incl. connectors, freeform, merge), themes and
|
||||
backgrounds, tables, charts, transitions, animations, slide show, audio/video playback, PDF export,
|
||||
AutoRecover, CLI/MCP automation, web build.
|
||||
### Readiness by audience
|
||||
|
||||
## How far from full parity (estimate, 2026-10-07)
|
||||
| Audience | Ready | Opus 5.5 agent hours to ~95% | Work that dominates |
|
||||
|---|---:|---|---|
|
||||
| Full target (ready for real work) | ~55% | 340–600 (~60% parallelizes: ~120–210 h wall clock with 3–4 agents) | Feature depth across all 20 areas, legacy `.ppt`/`.odp` and video export, localization in 11+ languages, GPU raster and hardware decode, AI features |
|
||||
| Mainstream practitioner | ~53% | 150–250 (~50% parallelizes: ~70–120 h with 3 agents) | `.pptx` exchange (real-deck corpus, round-trip losses, EMF/SVG, embedded fonts) 45–75 h; depth in charts, animations, pictures, text, presenting, print/vector PDF 70–110 h; interaction (menu bar, Format Shape pane, font faces, dialogs) 25–40 h; stability 10–20 h |
|
||||
| Essentials user | ~62% | 50–90 (~40% parallelizes: ~30–55 h with 2–3 agents) | Opening received decks (corpus subset, EMF, SVG icons) 25–45 h; Windows launch fallback and Close prompt 6–12 h; font faces, Tab, paste, print, show navigation 12–20 h; start screen, menu bar, palette 6–10 h |
|
||||
|
||||
**Breadth: ~79% weighted** (P0 core 93%, P1 74%, P2 35%) over the 187 features of the PowerPoint catalogue, scored row by
|
||||
row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored
|
||||
done still lack some of PowerPoint's options, dialogs or pixel fidelity, so **overall parity
|
||||
including depth is about 62%**.
|
||||
Calibration is the same as for the full number (see [docs/target-app-parity.md](docs/target-app-parity.md#calibration-how-the-hours-were-derived)): a fix is 0.5–1 h, a checklist row to PowerPoint depth 2–4 h, a subsystem 6–35 h. The sets are nested, so essentials ≤ mainstream ≤ full. The full number is an additive weighted sum over the dimension table; only mainstream and essentials use multiplicative discounts.
|
||||
|
||||
**Remaining work to 100%: about 190 wall-clock hours of a single Claude Opus 5.5 agent** (±30%),
|
||||
or roughly 65–90 hours with four agents in parallel on separate crates:
|
||||
**Why alpha:** all six of PowerPoint's core workflows pass the alpha gate
|
||||
([docs/roadmap.md](docs/roadmap.md#alpha-gate-powerpoints-core-workflows)): build, open and save
|
||||
`.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with
|
||||
transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish
|
||||
have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77),
|
||||
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
|
||||
[docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Work | Estimate |
|
||||
|---|---|
|
||||
| Alpha blockers above | 35 h |
|
||||
| Open P0/P1 rows (vector PDF, print, edit points, SVG, multi-monitor, draw table, SmartArt text pane, Notes/Handout masters…) | 45 h |
|
||||
| Open P2 rows (equations, video export, 3-D, remove background, thesaurus, compare, record show…) | 40 h |
|
||||
| Depth and pixel fidelity of every ribbon group, dialog and pane against PowerPoint | 50 h |
|
||||
| Performance (incremental rendering, GPU raster, streaming media decode) | 20 h |
|
||||
## By dimension
|
||||
|
||||
Basis: in this session the engine, renderer, UI, show engine, PPTX, PDF, media and release
|
||||
pipeline were built in about 30 wall-clock hours with up to three agents in parallel; recent
|
||||
catalogue rows (connectors, freeform, merge shapes, AutoRecover, gradients) took 1–2 hours each.
|
||||
| Dimension | Ready | Hours to full | Doc |
|
||||
|---|---:|---|---|
|
||||
| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | ~8% | 55–90 + native review | [docs/localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance (unmeasured) | ~55% | 10–20 | [docs/hardware-parity.md](docs/hardware-parity.md#performance) |
|
||||
| Stability | ~60% | 10–20 | [docs/gaps.md](docs/gaps.md#stability) |
|
||||
| Platforms | ~80% | 5–10 | [Downloads in README](README.md#downloads) |
|
||||
| Ecosystem (templates; add-ins out of scope) | ~25% | 10–20 | [docs/gaps.md](docs/gaps.md#ecosystem) |
|
||||
| AI features | ~10% | 20–40 | [docs/gaps.md](docs/gaps.md#ai-features) |
|
||||
| Automation ✱ (CLI, control channel, MCP) | ahead | 0 | [docs/mcp.md](docs/mcp.md) |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Breadth (measured) | Ready (est.) | Hours | Doc |
|
||||
|---|---:|---:|---|---|
|
||||
| Application shell | 71% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Slides and sections | 90% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
|
||||
| Views | 90% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
|
||||
| Masters and themes | 85% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
|
||||
| Shapes | 86% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Format (fill, line, effects) | 81% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Arrange | 100% | ~90% | 1–2 | [ui-parity](docs/ui-parity.md) |
|
||||
| Text | 80% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
|
||||
| Tables | 89% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
|
||||
| Charts | 65% | ~45% | 12–20 | [gaps](docs/gaps.md#charts-data-grid-elements-and-the-newer-chart-types) |
|
||||
| SmartArt | 33% | ~25% | 10–16 | [gaps](docs/gaps.md#smartart-is-pre-drawn-shapes-only) |
|
||||
| Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
|
||||
| Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) |
|
||||
| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
|
||||
| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
|
||||
| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
|
||||
| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) |
|
||||
| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Files (commands; formats above) | 69% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
|
||||
|
||||
## Languages
|
||||
|
||||
English only today; no string catalog yet (Ukrainian is in review as PR #34). Detail:
|
||||
[docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | UI strings | Status | Hours to full |
|
||||
|---|---|---:|---|---|
|
||||
| English | en | 100% | full | 0 |
|
||||
| Simplified Chinese | zh-Hans | 0% | none | 6–10 |
|
||||
| Spanish | es | 0% | none | 3–5 |
|
||||
| Hindi | hi | 0% | none | 4–7 |
|
||||
| Arabic | ar | 0% (slide text: bidi and shaping done) | none | 10–16 |
|
||||
| French | fr | 0% | none | 3–5 |
|
||||
| Portuguese | pt | 0% | none | 3–5 |
|
||||
| Indonesian | id | 0% | none | 3–5 |
|
||||
| Japanese | ja | 0% | none | 4–7 |
|
||||
| German | de | 0% | none | 3–5 |
|
||||
| Korean | ko | 0% | none | 4–7 |
|
||||
| Vietnamese | vi | 0% | none | 3–5 |
|
||||
|
||||
Plus 8–14 h once for the catalog infrastructure. Other languages shipped: none. PowerPoint for Mac
|
||||
ships 26.
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
|
||||
image paste (#53), editor soak tests. 10–20 h.
|
||||
2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
|
||||
36–64 h.
|
||||
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
|
||||
4. **Output:** vector PDF, printing. 10–16 h.
|
||||
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
|
||||
presenter view on the right display, native macOS menu bar, Format Shape pane. 33–56 h.
|
||||
|
||||
## Agents: CLI and MCP
|
||||
|
||||
Every command is reachable from `deckcraft-cli` (`run`, `describe`, `commands`, `app` for the running
|
||||
window, `render`, `convert`), from MCP (`deckcraft-cli mcp`, optionally `--connect PORT`), and from the
|
||||
app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
|
||||
| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
| 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
|
||||
| 2026-10-06 | Renamed SlideCraft to DeckCraft. PPTX import/export, PDF export, media playback with pure-Rust codecs, connectors with glue, freeform tools, merge shapes, AutoRecover, gradient editor, web build, release CI, parity catalogue |
|
||||
| 2026-10-05 | M0: workspace, model, geometry, fonts, text, renderer, engine, MCP, CLI, PowerPoint-style egui UI, slide show engine |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
|
||||
|
||||
@@ -14,7 +14,7 @@ workspace = true
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-ui-egui = { workspace = true }
|
||||
deckcraft-media = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -1,12 +1,50 @@
|
||||
//! cpal audio output for media playback: the device pulls mixed samples from the
|
||||
//! `deckcraft_media::Player`, which makes it the playback clock video frames follow.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use deckcraft_media::AudioOut;
|
||||
|
||||
/// The first stream error since it was last reported. cpal calls the error callback on its
|
||||
/// realtime audio thread (on ALSA in a loop while the device stays broken), which must not log:
|
||||
/// the callback only keeps the first error, and the UI thread logs it when the stream stops.
|
||||
type ErrorSlot = Arc<Mutex<Option<String>>>;
|
||||
|
||||
/// Keep `e` unless an error is already kept. Never blocks: a busy slot drops the error.
|
||||
fn keep_stream_error(slot: &ErrorSlot, e: &dyn std::fmt::Display) {
|
||||
if let Ok(mut kept) = slot.try_lock()
|
||||
&& kept.is_none()
|
||||
{
|
||||
*kept = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The kept error, once (a poisoned lock is tolerated).
|
||||
fn take_stream_error(slot: &ErrorSlot) -> Option<String> {
|
||||
slot.lock().unwrap_or_else(PoisonError::into_inner).take()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CpalOut {
|
||||
stream: Option<cpal::Stream>,
|
||||
error: ErrorSlot,
|
||||
}
|
||||
|
||||
impl CpalOut {
|
||||
/// Log the stream error the realtime callback kept (UI thread).
|
||||
fn report(&self) {
|
||||
if let Some(e) = take_stream_error(&self.error) {
|
||||
log::warn!("audio stream error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CpalOut {
|
||||
fn drop(&mut self) {
|
||||
self.stream = None;
|
||||
self.report();
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioOut for CpalOut {
|
||||
@@ -17,7 +55,8 @@ impl AudioOut for CpalOut {
|
||||
let channels = cfg.channels() as usize;
|
||||
let rate = cfg.sample_rate().0;
|
||||
let config: cpal::StreamConfig = cfg.clone().into();
|
||||
let err = |e| log::warn!("audio stream error: {e}");
|
||||
let slot = self.error.clone();
|
||||
let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e);
|
||||
let stream = match cfg.sample_format() {
|
||||
cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None),
|
||||
cpal::SampleFormat::I16 => {
|
||||
@@ -45,5 +84,37 @@ impl AudioOut for CpalOut {
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.stream = None;
|
||||
self.report();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_first_stream_error_is_kept_and_it_is_taken_once() {
|
||||
let slot = ErrorSlot::default();
|
||||
assert_eq!(take_stream_error(&slot), None);
|
||||
keep_stream_error(&slot, &"device unplugged");
|
||||
keep_stream_error(&slot, &"device unplugged again");
|
||||
assert_eq!(take_stream_error(&slot).as_deref(), Some("device unplugged"));
|
||||
assert_eq!(take_stream_error(&slot), None);
|
||||
keep_stream_error(&slot, &"a later error");
|
||||
assert_eq!(take_stream_error(&slot).as_deref(), Some("a later error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_slot_still_reports() {
|
||||
let slot = ErrorSlot::default();
|
||||
keep_stream_error(&slot, &"lost");
|
||||
let s = slot.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _g = s.lock();
|
||||
panic!("poison the slot");
|
||||
})
|
||||
.join();
|
||||
assert!(slot.is_poisoned());
|
||||
assert_eq!(take_stream_error(&slot).as_deref(), Some("lost"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("deckcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("deckcraft: control server listening on 127.0.0.1:{port}");
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
|
||||
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
|
||||
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
|
||||
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
|
||||
// workaround without mutating process environment variables.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
create.instance_descriptor.backends
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_initializes_only_dx12() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
|
||||
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
assert_eq!(configured_backends(None), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
assert_eq!(configured_backends(Some(backends)), backends);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the crash guard's panic
|
||||
//! report, a failed crash-recovery save, PowerPoint parts that were skipped on import, audio output
|
||||
//! problems) vanished. A launch from a desktop menu has no terminal, so the file is what a bug report
|
||||
//! can attach: `<settings dir>/logs/deckcraft.log`, beside `ui.json` and `prefs.json`. Each start
|
||||
//! moves the previous log to `deckcraft.1.log` (and that one to `.2`), so the log of a run that
|
||||
//! crashed survives the next launch.
|
||||
//!
|
||||
//! Levels: `info` for DeckCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
||||
//! `warn,deckcraft_pptx=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
||||
//! that starts with it (`deckcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or written
|
||||
//! leaves standard error as the only sink.
|
||||
//!
|
||||
//! Realtime audio: the cpal callbacks (`audio.rs`) do not log; a stream error is kept and logged
|
||||
//! from the UI thread when the stream stops. A record takes the sink lock only for one `write_all`
|
||||
//! of an already formatted line (standard error is written outside the lock).
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log directory.
|
||||
pub const LOG_FILE: &str = "deckcraft.log";
|
||||
/// The log directory inside the settings directory.
|
||||
pub const LOG_DIR: &str = "logs";
|
||||
/// How many previous logs are kept (`deckcraft.1.log` … `deckcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,deckcraft*=info";
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("deckcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// No file will come: drop the kept lines and keep none from now on.
|
||||
pub fn close(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink (and
|
||||
// stop buffering, since no file will come).
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
self.stderr_only();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file this run (no settings directory, or it can't be used): stop keeping records for one.
|
||||
pub fn stderr_only(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Format before taking the lock, so the lock covers one write only.
|
||||
let thread = std::thread::current();
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("deckcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_deckcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("deckcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("deckcraft::audio"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("deckcraft_engine::guard"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,deckcraft_pptx=trace,deckcraft_pptx::opc=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("deckcraft_pptx::opc"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_ui_egui::canvas"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_engine"), LevelFilter::Warn);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive; the last bare level wins.
|
||||
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "a".repeat(100_000);
|
||||
let specs = [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"deckcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=debug",
|
||||
"a=b=c",
|
||||
"warn,,deckcraft=DEBUG",
|
||||
" \t ",
|
||||
"\u{0}",
|
||||
"é*=trace",
|
||||
&long,
|
||||
];
|
||||
for spec in specs {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["deckcraft", "", "🦀", "é", "a::b"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,deckcraft=DEBUG").level_for("deckcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
||||
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
||||
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("deckcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("deckcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("deckcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
assert!(rotate(&dir.join("below")).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line(
|
||||
"2026-10-08T07:59:17.728Z",
|
||||
log::Level::Warn,
|
||||
"main",
|
||||
"deckcraft_pptx::opc",
|
||||
"pptx: skipping oversized part ppt/media/image1.png"
|
||||
),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_pending_lines_drop_nothing() {
|
||||
let dir = temp_dir("exact");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "deckcraft", "DeckCraft starting");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Debug, "deckcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("deckcraft: DeckCraft starting")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("deckcraft_engine::recovery").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// A second launch rotates: the first log becomes `.1`.
|
||||
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("deckcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..4 {
|
||||
s.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
record(&logger, log::Level::Info, "deckcraft", "from a worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 400);
|
||||
assert!(text.lines().all(|l| l.ends_with("deckcraft: from a worker")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
||||
assert!(e.contains(&dir.display().to_string()), "{e}");
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_lock_does_not_stop_logging() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = logger.sink.lock();
|
||||
panic!("poison the sink");
|
||||
}));
|
||||
assert!(logger.sink.is_poisoned());
|
||||
record(&logger, log::Level::Info, "deckcraft", "after the panic");
|
||||
assert!(read(&path).contains("after the panic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,18 @@
|
||||
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `deckcraft_ui_egui::control` for the methods.
|
||||
//!
|
||||
//! `log` records go to standard error and `<settings dir>/logs/deckcraft.log` (`RUST_LOG` sets the
|
||||
//! levels); see [`logging`].
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod audio;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_ui_egui::{Services, SlideApp};
|
||||
@@ -32,8 +38,9 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DeckCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DeckCraft"))
|
||||
@@ -42,8 +49,11 @@ fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("deckcraft"))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
config_dir().map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut SlideApp) {
|
||||
@@ -125,6 +135,9 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Session) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
let mut control_port: Option<u16> = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
@@ -142,6 +155,19 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// leaves no file behind. Records logged until now are written to it first.
|
||||
if let Some(logger) = logger {
|
||||
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
|
||||
Some(Ok(path)) => log::info!("log file {}", path.display()),
|
||||
Some(Err(e)) => log::warn!("no log file: {e}"),
|
||||
None => {
|
||||
logger.stderr_only();
|
||||
log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("DeckCraft")
|
||||
@@ -164,6 +190,9 @@ fn main() -> eframe::Result {
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
eframe::run_native(
|
||||
"DeckCraft",
|
||||
options,
|
||||
@@ -185,11 +214,11 @@ fn main() -> eframe::Result {
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
|
||||
eprintln!("deckcraft: sample: {e}");
|
||||
log::error!("sample: {e}");
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.open_path(&f) {
|
||||
eprintln!("deckcraft: {f}: {e}");
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
if show {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect x="16" y="16" width="480" height="480" rx="111.84" fill="#f26b1d"/><rect x="246" y="330" width="20" height="110" fill="#0b0b0c"/><rect x="176" y="430" width="160" height="18" fill="#0b0b0c"/><rect x="86" y="96" width="340" height="244" rx="14.64" fill="#efe9dc" stroke="#0b0b0c" stroke-width="12"/><rect x="126" y="136" width="170" height="20" fill="#0b0b0c"/><rect x="136" y="236" width="46" height="70" fill="#f26b1d"/><rect x="202" y="196" width="46" height="110" fill="#0b0b0c"/><rect x="268" y="166" width="46" height="140" fill="#f26b1d"/><circle cx="364" cy="178" r="28" fill="#0b0b0c"/></svg>
|
||||
|
After Width: | Height: | Size: 669 B |
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/deckcraft",
|
||||
"branch": "main",
|
||||
"head": "4b09f35c1d6fa50e2d94243a5d65d98fe49558e0",
|
||||
"head_date": "2026-10-09T14:48:26Z",
|
||||
"commits": 41,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 28,
|
||||
"lines_added": 124410,
|
||||
"lines_deleted": 3748,
|
||||
"binary_added": 47,
|
||||
"binary_deleted": 12,
|
||||
"first_commit": "2026-10-06T02:13:02Z",
|
||||
"last_commit": "2026-10-09T12:51:28Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 2,
|
||||
"lines_added": 56,
|
||||
"lines_deleted": 56,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:13:34Z",
|
||||
"last_commit": "2026-10-08T14:02:34Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 241,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:12:32Z",
|
||||
"last_commit": "2026-10-08T13:12:32Z"
|
||||
},
|
||||
{
|
||||
"login": "Pedroj-64",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 162,
|
||||
"lines_deleted": 42,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:31Z",
|
||||
"last_commit": "2026-10-09T14:28:31Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 706,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:35Z",
|
||||
"last_commit": "2026-10-09T14:29:47Z"
|
||||
},
|
||||
{
|
||||
"login": "alexanderalber",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 718,
|
||||
"lines_deleted": 53,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:40Z",
|
||||
"last_commit": "2026-10-09T14:32:41Z"
|
||||
},
|
||||
{
|
||||
"login": "wurstbrotdlx",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 65,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:45Z",
|
||||
"last_commit": "2026-10-09T14:28:45Z"
|
||||
},
|
||||
{
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 265,
|
||||
"lines_deleted": 77,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:49Z",
|
||||
"last_commit": "2026-10-09T14:40:13Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 67,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:30:12Z",
|
||||
"last_commit": "2026-10-09T14:30:12Z"
|
||||
},
|
||||
{
|
||||
"login": "hamzaelkachtaf4-star",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1902,
|
||||
"lines_deleted": 282,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:48:26Z",
|
||||
"last_commit": "2026-10-09T14:48:26Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 33,
|
||||
"lines_added": 126890,
|
||||
"lines_deleted": 4162
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Fable",
|
||||
"version": "5.1",
|
||||
"commits": 1,
|
||||
"lines_added": 65,
|
||||
"lines_deleted": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.DimaSmaha]
|
||||
real_name = "Dmytro Smaha"
|
||||
display_name = "Dima Smaha"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Johannesgauss]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
@@ -3,7 +3,8 @@
|
||||
//! - [`Timeline`]: a slide's animation list grouped into click steps, with per-shape (and
|
||||
//! per-paragraph) [`AnimState`] at any point of the show.
|
||||
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
|
||||
//! - [`morph_pairs`] / [`morph_xfrm`]: Morph transition shape matching and interpolation.
|
||||
//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by
|
||||
//! [`morph_xfrm`]).
|
||||
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
|
||||
//!
|
||||
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
|
||||
@@ -26,9 +27,9 @@ pub use deckcraft_model::Rgba;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use easing::ease;
|
||||
pub use morph::{morph_pairs, morph_xfrm};
|
||||
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
|
||||
pub use path::{MotionPath, default_path};
|
||||
pub use show::{ShowAction, ShowState, show_order};
|
||||
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
|
||||
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
|
||||
pub use transition::{Layer, Source, transition_layers};
|
||||
|
||||
|
||||
+161
-2
@@ -1,8 +1,10 @@
|
||||
//! Morph: matching shapes between two slides and interpolating their boxes.
|
||||
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
|
||||
|
||||
use deckcraft_model::{Shape, ShapeId, Slide, Xfrm};
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
||||
|
||||
use crate::clampf;
|
||||
use crate::easing::smooth;
|
||||
|
||||
fn text_of(s: &Shape) -> String {
|
||||
s.text.as_ref().map(|t| t.text()).unwrap_or_default()
|
||||
@@ -91,3 +93,160 @@ pub fn morph_xfrm(a: Xfrm, b: Xfrm, t: f64) -> Xfrm {
|
||||
flip_v: if late { b.flip_v } else { a.flip_v },
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of a Morph transition, ready for the renderer's `render_blend`: the backdrops of the
|
||||
/// two slides cross-fade by [`MorphFrame::mix`], the shapes are drawn in order on top.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MorphFrame {
|
||||
/// Shapes with fresh ids, each flagged `true` when it comes from the old slide (it resolves
|
||||
/// its placeholder, theme and style against that slide).
|
||||
pub shapes: Vec<(Shape, bool)>,
|
||||
/// Opacity of the frame's shapes by id; shapes not listed are opaque.
|
||||
pub opacity: Vec<(ShapeId, f64)>,
|
||||
pub mix: f64,
|
||||
/// With the Words or Characters option: pairs of frame shapes whose texts morph by words or
|
||||
/// characters instead of cross-fading (the renderer's `TextMorph`).
|
||||
pub text: Vec<MorphText>,
|
||||
/// Frame shapes whose outline turns from another geometry into their own (the renderer's
|
||||
/// `PathMorph`).
|
||||
pub paths: Vec<MorphPath>,
|
||||
}
|
||||
|
||||
/// A frame shape whose outline morphs from `from` (the old shape's geometry) into its own.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MorphPath {
|
||||
pub id: ShapeId,
|
||||
pub from: Geom,
|
||||
/// The boxes of the old and the new shape on their own slides.
|
||||
pub from_box: Xfrm,
|
||||
pub to_box: Xfrm,
|
||||
}
|
||||
|
||||
/// Two frame shapes whose texts morph by words or characters.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MorphText {
|
||||
/// Frame shape ids: from the old slide, from the new slide.
|
||||
pub old: ShapeId,
|
||||
pub new: ShapeId,
|
||||
/// The boxes of the two shapes on their own slides.
|
||||
pub from: Xfrm,
|
||||
pub to: Xfrm,
|
||||
pub chars: bool,
|
||||
}
|
||||
|
||||
impl MorphFrame {
|
||||
/// Opacity of shape `id` of the frame.
|
||||
pub fn opacity_of(&self, id: ShapeId) -> f64 {
|
||||
self.opacity.iter().find(|(s, _)| *s == id).map(|(_, o)| *o).unwrap_or(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn mix_color(a: Rgba, b: Rgba, t: f64) -> Rgba {
|
||||
let m = |x: u8, y: u8| clampf(lerp(x as f64, y as f64, t), 0.0, 255.0, 0.0).round() as u8;
|
||||
Rgba { r: m(a.r, b.r), g: m(a.g, b.g), b: m(a.b, b.b), a: m(a.a, b.a) }
|
||||
}
|
||||
|
||||
fn solid_color(ctx: Option<&Ctx>, s: &Shape) -> Option<Rgba> {
|
||||
let ctx = ctx?;
|
||||
match resolve::fill(ctx, s) {
|
||||
(Some(Fill::Solid { color }), ph) => Some(ctx.color(&color, ph)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn box_of(ctx: Option<&Ctx>, s: &Shape) -> Option<Xfrm> {
|
||||
match ctx {
|
||||
Some(c) => Some(resolve::xfrm(c, s)),
|
||||
None => s.xfrm,
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of a shape apart from what Morph interpolates (identity, box and fill).
|
||||
fn content(s: &Shape) -> Shape {
|
||||
Shape { id: ShapeId(0), name: String::new(), xfrm: None, fill: None, ..s.clone() }
|
||||
}
|
||||
|
||||
/// Whether two shapes differ in their geometry only, and it is drawn as an outline (shapes,
|
||||
/// pictures, connectors), so the outline can morph.
|
||||
fn reshaped(a: &Shape, b: &Shape) -> bool {
|
||||
let outlined = |s: &Shape| matches!(s.kind, ShapeKind::Shape | ShapeKind::Picture { .. } | ShapeKind::Connector { .. });
|
||||
a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) }
|
||||
}
|
||||
|
||||
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
|
||||
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
|
||||
/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
|
||||
/// otherwise the new shape fades in under the fading old one on the same moving
|
||||
/// box; with the transition option `words` or `characters` their texts are listed in
|
||||
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
|
||||
/// which `hidden_b` is true (entrance animations, hidden media) stay out of the frame.
|
||||
pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: &dyn Fn(ShapeId) -> bool) -> MorphFrame {
|
||||
let s = smooth(clampf(t, 0.0, 1.0, 0.0));
|
||||
let ca = Ctx::for_slide(pres, a);
|
||||
let cb = Ctx::for_slide(pres, b);
|
||||
let pairs: Vec<(ShapeId, ShapeId)> = morph_pairs(a, b).into_iter().filter(|(_, ib)| !hidden_b(*ib)).collect();
|
||||
let mut next = 1u32;
|
||||
walk(&a.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
|
||||
walk(&b.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
|
||||
let mut f = MorphFrame { mix: s, ..Default::default() };
|
||||
let by = b.transition.as_ref().map(|tr| tr.option.as_str()).unwrap_or("");
|
||||
let (by_text, chars) = (by == "words" || by == "characters", by == "characters");
|
||||
let has_text = |x: &Shape| x.text.as_ref().is_some_and(|t| !t.is_empty()) && !matches!(x.kind, ShapeKind::Group { .. } | ShapeKind::Table(_));
|
||||
let mut text = Vec::new();
|
||||
let mut paths = Vec::new();
|
||||
let mut push = |mut sh: Shape, old: bool, op: f64| {
|
||||
let id = ShapeId(next);
|
||||
sh.id = id;
|
||||
next = next.saturating_add(1);
|
||||
if op < 1.0 {
|
||||
f.opacity.push((id, op));
|
||||
}
|
||||
f.shapes.push((sh, old));
|
||||
id
|
||||
};
|
||||
for sa in &a.shapes {
|
||||
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
|
||||
push(sa.clone(), true, 1.0 - s);
|
||||
}
|
||||
}
|
||||
for sb in &b.shapes {
|
||||
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
|
||||
let Some(sa) = pair else {
|
||||
if !hidden_b(sb.id) {
|
||||
push(sb.clone(), false, s);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let (xa, xb) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb));
|
||||
let x = match (xa, xb) {
|
||||
(Some(xa), Some(xb)) => Some(morph_xfrm(xa, xb, s)),
|
||||
(_, xb) => xb,
|
||||
};
|
||||
let reshape = reshaped(sa, sb);
|
||||
if reshape || content(sa) == content(sb) {
|
||||
let mut m = Shape { xfrm: x, ..sb.clone() };
|
||||
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
|
||||
&& fa != fb
|
||||
{
|
||||
m.fill = Some(Fill::solid(ColorRef::rgb(mix_color(fa, fb, s))));
|
||||
}
|
||||
let id = push(m, false, 1.0);
|
||||
if reshape && let (Some(from_box), Some(to_box)) = (xa, xb) {
|
||||
paths.push(MorphPath { id, from: sa.geom.clone(), from_box, to_box });
|
||||
}
|
||||
} else {
|
||||
let new = push(Shape { xfrm: x, ..sb.clone() }, false, s);
|
||||
let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s);
|
||||
if by_text
|
||||
&& has_text(sa)
|
||||
&& has_text(sb)
|
||||
&& let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb))
|
||||
{
|
||||
text.push(MorphText { old, new, from, to, chars });
|
||||
}
|
||||
}
|
||||
}
|
||||
f.text = text;
|
||||
f.paths = paths;
|
||||
f
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! and automatic advance.
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::Action;
|
||||
|
||||
use crate::Timeline;
|
||||
|
||||
@@ -25,6 +26,32 @@ pub enum ShowAction {
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// Can a link from a (possibly untrusted) deck be opened outside the show? Only web pages and
|
||||
/// mail: `file:`, `javascript:`, relative paths and other schemes (OS protocol handlers) are not
|
||||
/// opened on a click.
|
||||
pub fn safe_url(url: &str) -> bool {
|
||||
let u = url.trim();
|
||||
let Some((scheme, rest)) = u.split_once(':') else { return false };
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
|
||||
"mailto" => !rest.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a clicked link does in the show (`ShowState::follow`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LinkJump {
|
||||
/// Navigation inside the show (already applied to the state).
|
||||
Show(ShowAction),
|
||||
/// Open this URL (`http`, `https` or `mailto`, see [`safe_url`]) outside the show.
|
||||
Open(String),
|
||||
/// End the show.
|
||||
Exit,
|
||||
/// Nothing to follow.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Where a slide show is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub struct ShowState {
|
||||
@@ -211,6 +238,27 @@ impl ShowState {
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
|
||||
/// Follow a clicked hyperlink or shape action. `last_viewed` is the slide shown before the
|
||||
/// current one (Last Slide Viewed). Moves to slide targets; everything else is for the UI.
|
||||
pub fn follow(&mut self, pres: &Presentation, action: &Action, last_viewed: Option<usize>) -> LinkJump {
|
||||
let target = match action {
|
||||
Action::Url { url } => return if safe_url(url) { LinkJump::Open(url.trim().to_string()) } else { LinkJump::None },
|
||||
Action::EndShow => return LinkJump::Exit,
|
||||
Action::Slide { slide } => pres.slides.iter().position(|s| s.id == *slide),
|
||||
Action::NextSlide => self.next_index(pres),
|
||||
Action::PreviousSlide => self.prev_index(pres),
|
||||
Action::FirstSlide => show_order(pres).first().copied(),
|
||||
Action::LastSlide => show_order(pres).last().copied(),
|
||||
Action::LastViewed => last_viewed,
|
||||
// Custom shows, programs and media actions aren't followed (yet).
|
||||
Action::CustomShow { .. } | Action::Program { .. } | Action::PlayMedia => None,
|
||||
};
|
||||
match target {
|
||||
Some(i) => LinkJump::Show(self.goto(pres, i)),
|
||||
None => LinkJump::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the
|
||||
/// show uses timings.
|
||||
pub fn advance_after(&self, pres: &Presentation) -> Option<f64> {
|
||||
|
||||
@@ -543,6 +543,117 @@ fn morph_xfrm_interpolates() {
|
||||
assert!(m.rot.is_finite());
|
||||
}
|
||||
|
||||
fn solid(s: Shape, r: u8) -> Shape {
|
||||
Shape { fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r, g: 0, b: 0, a: 255 }))), ..s }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_moves_blends_and_fades() {
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
let moved = |id, x| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), ..shape(id, "Box") };
|
||||
a.shapes = vec![solid(moved(1, 0.0), 0), shape(2, "Gone")];
|
||||
let new = Shape { text: Some(TextBody::from_text("Hi")), ..shape(3, "New") };
|
||||
b.shapes = vec![solid(moved(1, 400.0), 200), new, shape(4, "Later")];
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|id| id == ShapeId(4));
|
||||
assert!((f.mix - 0.5).abs() < 1e-9);
|
||||
// Gone fades out, Box moves halfway and blends red, New fades in, Later stays out.
|
||||
let names: Vec<(&str, bool)> = f.shapes.iter().map(|(s, old)| (s.name.as_str(), *old)).collect();
|
||||
assert_eq!(names, [("Gone", true), ("Box", false), ("New", false)]);
|
||||
let m = &f.shapes[1].0;
|
||||
assert!((m.xfrm.unwrap().x - 200.0).abs() < 1e-9);
|
||||
assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 }))));
|
||||
assert_eq!(f.opacity_of(m.id), 1.0);
|
||||
assert!((f.opacity_of(f.shapes[0].0.id) - 0.5).abs() < 1e-9);
|
||||
assert!((f.opacity_of(f.shapes[2].0.id) - 0.5).abs() < 1e-9);
|
||||
// Ids are fresh and unique.
|
||||
let mut ids: Vec<_> = f.shapes.iter().map(|(s, _)| s.id).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert!(ids.iter().all(|i| i.0 > 4));
|
||||
// At the start only the old slide shows.
|
||||
let f0 = morph_frame(&p, &a, &b, 0.0, &|id| id == ShapeId(4));
|
||||
assert!(f0.shapes.iter().filter(|(s, _)| f0.opacity_of(s.id) > 0.0).all(|(s, _)| s.name != "New"));
|
||||
assert_eq!(morph_frame(&p, &a, &b, f64::NAN, &|_| false).mix, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_cross_fades_changed_content() {
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![Shape { text: Some(TextBody::from_text("Before")), ..shape(1, "T") }];
|
||||
b.shapes = vec![Shape { text: Some(TextBody::from_text("After")), ..shape(1, "T") }];
|
||||
let f = morph_frame(&p, &a, &b, 0.25, &|_| false);
|
||||
// The new text under the fading old one, both on the moving box.
|
||||
assert_eq!(f.shapes.len(), 2);
|
||||
assert_eq!((&f.shapes[0].0.text, f.shapes[0].1), (&b.shapes[0].text, false));
|
||||
assert_eq!((&f.shapes[1].0.text, f.shapes[1].1), (&a.shapes[0].text, true));
|
||||
let s = f.mix;
|
||||
assert!((f.opacity_of(f.shapes[0].0.id) - s).abs() < 1e-9);
|
||||
assert!((f.opacity_of(f.shapes[1].0.id) - (1.0 - s)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_lists_texts_for_words_and_characters() {
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![Shape { text: Some(TextBody::from_text("The Science")), ..shape(1, "T") }];
|
||||
b.shapes = vec![Shape { text: Some(TextBody::from_text("Science rocks")), ..shape(1, "T") }];
|
||||
for (option, want) in [("objects", None), ("words", Some(false)), ("characters", Some(true))] {
|
||||
b.transition = Some(Transition { kind: "morph".into(), option: option.into(), ..Default::default() });
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
assert_eq!(f.text.first().map(|m| m.chars), want, "{option}");
|
||||
if let Some(m) = f.text.first() {
|
||||
assert_eq!((m.new, m.old), (f.shapes[0].0.id, f.shapes[1].0.id));
|
||||
assert_eq!(m.from, a.shapes[0].xfrm.unwrap());
|
||||
}
|
||||
}
|
||||
// Same text: one shape moves, nothing to morph.
|
||||
b.shapes[0].text = a.shapes[0].text.clone();
|
||||
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_morphs_the_outline_of_a_changed_geometry() {
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
let geo = |s: Shape, g: &str| Shape { geom: deckcraft_model::Geom::preset(g), ..s };
|
||||
a.shapes = vec![solid(geo(shape(1, "Form"), "ellipse"), 0)];
|
||||
b.shapes = vec![solid(Shape { xfrm: Some(Xfrm::new(300.0, 100.0, 100.0, 100.0)), ..geo(shape(1, "Form"), "star5") }, 200)];
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
// One shape: the new one, on the moving box, its colour blending and its outline listed to morph.
|
||||
assert_eq!(f.shapes.len(), 1);
|
||||
let (m, old) = &f.shapes[0];
|
||||
assert!(!old && m.geom == b.shapes[0].geom);
|
||||
assert_eq!(f.opacity_of(m.id), 1.0);
|
||||
assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 }))));
|
||||
assert_eq!(f.paths.len(), 1);
|
||||
assert_eq!(f.paths[0].id, m.id);
|
||||
assert_eq!(f.paths[0].from, a.shapes[0].geom);
|
||||
assert_eq!((f.paths[0].from_box, f.paths[0].to_box), (a.shapes[0].xfrm.unwrap(), b.shapes[0].xfrm.unwrap()));
|
||||
// Other adjust values of the same preset morph too.
|
||||
b.shapes[0].geom = deckcraft_model::Geom::Preset { name: "ellipse".into(), adj: vec![1.0] };
|
||||
assert_eq!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.len(), 1);
|
||||
// The same geometry only moves.
|
||||
b.shapes[0].geom = a.shapes[0].geom.clone();
|
||||
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.is_empty());
|
||||
// Another text as well: the two cross-fade as before.
|
||||
b.shapes[0].geom = deckcraft_model::Geom::preset("star5");
|
||||
b.shapes[0].text = Some(TextBody::from_text("Star"));
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
assert!(f.paths.is_empty() && f.shapes.len() == 2);
|
||||
// Tables and groups have no outline to morph.
|
||||
let table = |g: &str| Shape { kind: deckcraft_model::ShapeKind::Table(Default::default()), ..geo(shape(1, "Form"), g) };
|
||||
a.shapes = vec![table("rect")];
|
||||
b.shapes = vec![table("ellipse")];
|
||||
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.is_empty());
|
||||
}
|
||||
|
||||
fn pres(n: usize) -> Presentation {
|
||||
Presentation { slides: (0..n).map(|i| Arc::new(Slide { id: SlideId(1000 + i as u32), ..Default::default() })).collect(), ..Default::default() }
|
||||
}
|
||||
@@ -650,3 +761,31 @@ fn compose_and_sanitize() {
|
||||
let bad = AnimState { opacity: f64::NAN, rotate: f64::INFINITY, clip: Some([f64::NAN, 2.0, -1.0, 0.5]), ..Default::default() }.sanitized();
|
||||
assert!(finite(&bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_follows_links() {
|
||||
use deckcraft_model::text::Action;
|
||||
let mut p = pres(4);
|
||||
Arc::make_mut(&mut p.slides[3]).hidden = true;
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2)));
|
||||
assert_eq!(s.follow(&p, &Action::PreviousSlide, None), LinkJump::Show(ShowAction::Slide(1)));
|
||||
assert_eq!(s.follow(&p, &Action::NextSlide, None), LinkJump::Show(ShowAction::Slide(2)));
|
||||
// Last skips the hidden slide; a link to the hidden slide shows it.
|
||||
assert_eq!(s.follow(&p, &Action::LastSlide, None), LinkJump::Show(ShowAction::Slide(2)));
|
||||
assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(1003) }, None), LinkJump::Show(ShowAction::Slide(3)));
|
||||
assert_eq!(s.follow(&p, &Action::FirstSlide, None), LinkJump::Show(ShowAction::Slide(0)));
|
||||
assert_eq!(s.follow(&p, &Action::LastViewed, Some(3)), LinkJump::Show(ShowAction::Slide(3)));
|
||||
assert_eq!(s.slide, 3);
|
||||
assert_eq!(s.follow(&p, &Action::Url { url: "https://example.org".into() }, None), LinkJump::Open("https://example.org".into()));
|
||||
for bad in
|
||||
["file:///etc/passwd", "javascript:alert(1)", "ms-settings:", "smb://host/share", "C:\\Windows\\calc.exe", "../notes.txt", "https:", ""]
|
||||
{
|
||||
assert_eq!(s.follow(&p, &Action::Url { url: bad.into() }, None), LinkJump::None, "{bad} must not open");
|
||||
}
|
||||
assert_eq!(s.follow(&p, &Action::Url { url: " MAILTO:a@example.org".into() }, None), LinkJump::Open("MAILTO:a@example.org".into()));
|
||||
assert_eq!(s.follow(&p, &Action::EndShow, None), LinkJump::Exit);
|
||||
assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(9) }, None), LinkJump::None, "a link to a deleted slide does nothing");
|
||||
assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None);
|
||||
assert_eq!(s.slide, 3);
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ fn scale_rect(r: R, s: f64) -> R {
|
||||
/// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at
|
||||
/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone;
|
||||
/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph
|
||||
/// frames with [`crate::morph_pairs`]).
|
||||
/// frames with [`crate::morph_frame`]).
|
||||
pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec<Layer> {
|
||||
let t = clampf(t, 0.0, 1.0, 0.0);
|
||||
let v = if t >= 1.0 {
|
||||
|
||||
@@ -134,6 +134,12 @@ impl<'a> BitReader<'a> {
|
||||
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// `ue(v) + add` (the `..._minus1` / `..._minus8` fields), failing instead of overflowing on a
|
||||
/// hostile value near `u32::MAX`.
|
||||
pub fn read_ue_plus(&mut self, add: u32) -> Result<u32> {
|
||||
self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Signed Exp-Golomb `se(v)`.
|
||||
pub fn read_se(&mut self) -> Result<i32> {
|
||||
let k = self.read_ue()? as i64;
|
||||
@@ -343,6 +349,18 @@ mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn read_ue_plus_rejects_overflow() {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(7);
|
||||
w.write_ue(u32::MAX);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
assert_eq!(r.read_ue_plus(1).unwrap(), 8);
|
||||
assert!(r.read_ue_plus(1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_bits() {
|
||||
let d = [0b1010_1100, 0b0101_0011];
|
||||
|
||||
@@ -49,6 +49,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
options
|
||||
),
|
||||
cmd!("animation.clear", "Remove All Animations", [], None, "{index?: slide}", has_slide, clear),
|
||||
cmd!(
|
||||
noundo "animation.painter",
|
||||
"Animation Painter",
|
||||
["Animations", "Advanced Animation"],
|
||||
Some("Alt+Shift+C"),
|
||||
"{sticky?: bool, id?}",
|
||||
has_selection,
|
||||
painter_pick
|
||||
),
|
||||
cmd!("animation.painterApply", "Apply Animation", [], Some("Alt+Shift+V"), "{ids?}", has_selection, painter_apply),
|
||||
cmd!(query "animation.list", "Animation Gallery", [], None, "{} → [{id, label, class, options}]", always, list),
|
||||
cmd!(query "animation.get", "Animations on Slide", [], None, "{slide?} → [animation]", has_slide, get),
|
||||
]
|
||||
@@ -292,3 +302,152 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sl = st.doc.slides.get(i).ok_or_else(|| bad("animation.get", "no such slide"))?;
|
||||
Ok(serde_json::to_value(&sl.animations).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn painter_pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = id_param(p, "id")
|
||||
.or_else(|| st.selection.shapes.first().copied())
|
||||
.ok_or_else(|| bad("animation.painter", "select an animated shape first"))?;
|
||||
let sl = st.current_slide().ok_or_else(|| bad("animation.painter", "no slide"))?;
|
||||
let anims: Vec<Animation> = sl.animations.iter().filter(|a| a.shape == id).cloned().collect();
|
||||
if anims.is_empty() {
|
||||
return Err(bad("animation.painter", format!("shape {id} has no animations to copy")));
|
||||
}
|
||||
let sticky = bool_or(p, "sticky", false);
|
||||
let count = anims.len();
|
||||
s.anim_painter = Some((anims, sticky));
|
||||
Ok(json!({"armed": true, "count": count}))
|
||||
}
|
||||
|
||||
fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some((painted, sticky)) = s.anim_painter.clone() else {
|
||||
return Err(bad("animation.painterApply", "pick animations with the Animation Painter first"));
|
||||
};
|
||||
if !sticky {
|
||||
s.anim_painter = None;
|
||||
}
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("animation.painterApply", "no target shapes selected"));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.painterApply", "no slide"))?);
|
||||
// As in PowerPoint, painting replaces the target's own animations (painting a shape onto
|
||||
// itself doesn't double them). A paragraph effect keeps its paragraph only if the target
|
||||
// has that paragraph; otherwise it animates the whole shape.
|
||||
sl.animations.retain(|a| !ids.contains(&a.shape));
|
||||
let mut added = 0;
|
||||
for id in &ids {
|
||||
let paras = deckcraft_model::find_shape(&sl.shapes, *id).and_then(|sh| sh.text.as_ref()).map_or(0, |t| t.paragraphs.len());
|
||||
for a in &painted {
|
||||
let mut cloned = a.clone();
|
||||
cloned.shape = *id;
|
||||
if cloned.paragraph.is_some_and(|i| i as usize >= paras) {
|
||||
cloned.paragraph = None;
|
||||
}
|
||||
sl.animations.push(cloned);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"applied": added}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn animation_painter_copies_effects_between_shapes() {
|
||||
let mut s = Session::with_new();
|
||||
// Insert two shapes
|
||||
let s1 = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id1 = s1.get("id").unwrap().as_u64().unwrap();
|
||||
let s2 = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [150.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id2 = s2.get("id").unwrap().as_u64().unwrap();
|
||||
|
||||
// Add 2 animations to shape 1: fade (entrance) and spin (emphasis)
|
||||
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id1]})).unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "spin", "class": "emphasis", "ids": [id1]})).unwrap();
|
||||
|
||||
// Shape 2 initially has no animations
|
||||
let anims_before = s.execute("animation.get", &json!({})).unwrap();
|
||||
let arr_before = anims_before.as_array().unwrap();
|
||||
assert_eq!(arr_before.len(), 2);
|
||||
assert!(arr_before.iter().all(|a| a.get("shape").unwrap().as_u64().unwrap() == id1));
|
||||
|
||||
// Pick animations from shape 1
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![deckcraft_model::ShapeId(id1 as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
let res = s.execute("animation.painter", &json!({})).unwrap();
|
||||
assert_eq!(res.get("armed").unwrap().as_bool(), Some(true));
|
||||
assert_eq!(res.get("count").unwrap().as_u64(), Some(2));
|
||||
assert!(s.anim_painter.is_some());
|
||||
|
||||
// Apply animations to shape 2
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![deckcraft_model::ShapeId(id2 as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
let applied = s.execute("animation.painterApply", &json!({})).unwrap();
|
||||
assert_eq!(applied.get("applied").unwrap().as_u64(), Some(2));
|
||||
|
||||
// Single-click should disarm the painter
|
||||
assert!(s.anim_painter.is_none());
|
||||
|
||||
// Painting again replaces shape 2's animations instead of adding to them; undo restores.
|
||||
s.execute("animation.painter", &json!({"id": id1})).unwrap();
|
||||
s.execute("animation.painterApply", &json!({"ids": [id2]})).unwrap();
|
||||
let count_on = |s: &mut Session, id: u64| {
|
||||
let all = s.execute("animation.get", &json!({})).unwrap();
|
||||
all.as_array().unwrap().iter().filter(|a| a.get("shape").unwrap().as_u64().unwrap() == id).count()
|
||||
};
|
||||
assert_eq!(count_on(&mut s, id2), 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(count_on(&mut s, id2), 2, "undo restores the first painting");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(count_on(&mut s, id2), 0, "undo removes the painted animations");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
|
||||
// Verify shape 2 now has the 2 animations cloned
|
||||
let anims_after = s.execute("animation.get", &json!({})).unwrap();
|
||||
let arr_after = anims_after.as_array().unwrap();
|
||||
assert_eq!(arr_after.len(), 4);
|
||||
let s2_anims: Vec<_> = arr_after.iter().filter(|a| a.get("shape").unwrap().as_u64().unwrap() == id2).collect();
|
||||
assert_eq!(s2_anims.len(), 2);
|
||||
assert_eq!(s2_anims[0].get("effect").unwrap().as_str(), Some("fade"));
|
||||
assert_eq!(s2_anims[1].get("effect").unwrap().as_str(), Some("spin"));
|
||||
|
||||
// Picking from shape with no animations returns an error
|
||||
s.execute("animation.clear", &json!({})).unwrap();
|
||||
let err = s.execute("animation.painter", &json!({"id": id1}));
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_painter_sticky_mode() {
|
||||
let mut s = Session::with_new();
|
||||
let s1 = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id1 = s1.get("id").unwrap().as_u64().unwrap();
|
||||
let s2 = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [150.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id2 = s2.get("id").unwrap().as_u64().unwrap();
|
||||
let s3 = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [300.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id3 = s3.get("id").unwrap().as_u64().unwrap();
|
||||
|
||||
s.execute("animation.add", &json!({"effect": "zoom", "class": "entrance", "ids": [id1]})).unwrap();
|
||||
|
||||
// Pick with sticky = true
|
||||
s.execute("animation.painter", &json!({"id": id1, "sticky": true})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
|
||||
// Apply to shape 2 - painter remains armed
|
||||
s.execute("animation.painterApply", &json!({"ids": [id2]})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
|
||||
// Apply to shape 3 - painter still armed
|
||||
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
|
||||
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
|
||||
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
|
||||
cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder),
|
||||
cmd!(
|
||||
"arrange.reorder",
|
||||
"Reorder Objects",
|
||||
["Arrange"],
|
||||
None,
|
||||
"{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}",
|
||||
has_slide,
|
||||
reorder
|
||||
),
|
||||
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
|
||||
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
|
||||
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
|
||||
@@ -50,52 +58,98 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
// Already in place (front of its group, say): no change and no undo step.
|
||||
let mut trial = s.doc()?.shapes().to_vec();
|
||||
order_tree(&mut trial, &ids, how, 0);
|
||||
if stacking(&trial) == stacking(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
order_tree(list, &ids, how, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Every shape id with its depth, in tree order.
|
||||
fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> {
|
||||
let mut out = vec![];
|
||||
deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves
|
||||
/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way).
|
||||
fn order_tree(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if list.iter().any(|x| ids.contains(&x.id)) {
|
||||
order_list(list, ids, how);
|
||||
}
|
||||
for sh in list.iter_mut() {
|
||||
if let Some(children) = sh.children_mut() {
|
||||
order_tree(children, ids, how, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
|
||||
/// A shape stays in the list that holds it, so a group's child stays in its group.
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
|
||||
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
|
||||
let st = s.doc()?;
|
||||
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
|
||||
None => return Err(bad("arrange.reorder", "no such shape")),
|
||||
Some(None) => st.shapes(),
|
||||
Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]),
|
||||
};
|
||||
let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
if from == to.min(siblings.len().saturating_sub(1)) {
|
||||
return Ok(json!({"index": from}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?;
|
||||
let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let sh = list.remove(i);
|
||||
list.insert(to.min(list.len()), sh);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
let at = to.min(list.len());
|
||||
list.insert(at, sh);
|
||||
Ok(json!({"index": at}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounds of shapes (rotation included).
|
||||
@@ -221,7 +275,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
|
||||
};
|
||||
edit_shapes(s, p, "arrange.align", |sh| {
|
||||
edit_geometry(s, p, "arrange.align", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let b = x.bounds();
|
||||
let (dx, dy) = match edge.as_str() {
|
||||
@@ -273,7 +327,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets_pos.push((*id, d));
|
||||
cur += (if horiz { b.width() } else { b.height() }) + gap;
|
||||
}
|
||||
edit_shapes(s, p, "arrange.distribute", |sh| {
|
||||
edit_geometry(s, p, "arrange.distribute", |sh| {
|
||||
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
|
||||
if horiz {
|
||||
x.x += d;
|
||||
|
||||
@@ -307,6 +307,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
hf.hide_on_title = v;
|
||||
}
|
||||
let hf = hf.clone();
|
||||
let date_given = str_param(p, "dateText").is_some();
|
||||
let all = bool_or(p, "all", true);
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
|
||||
for i in list {
|
||||
@@ -372,6 +373,11 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sh.text = Some(TextBody::from_text(&hf.footer_text));
|
||||
}
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Date && date_given => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
set_date(sh, &hf.date_text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -381,19 +387,32 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Today's date as M/D/YYYY (no clock crate: computed from the system time).
|
||||
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
|
||||
/// `fixed`, at an automatic date field (one it already has keeps its format).
|
||||
fn set_date(sh: &mut Shape, fixed: &str) {
|
||||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||||
let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
|
||||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||||
let (text, kind) = match fixed {
|
||||
"" if has_field => return,
|
||||
"" => (today(), RunKind::Field { field: "datetime1".into() }),
|
||||
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
|
||||
_ => (fixed.to_string(), RunKind::Text),
|
||||
};
|
||||
let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() };
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() });
|
||||
}
|
||||
|
||||
/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date
|
||||
/// field. Empty where there is no clock (wasm).
|
||||
pub fn today() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let days = (secs / 86_400) as i64;
|
||||
let (y, m, d) = civil(days);
|
||||
format!("{m}/{d}/{y}")
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
date_field_text("datetime1")
|
||||
}
|
||||
|
||||
/// What date field `kind` shows now (empty if unknown or there is no clock).
|
||||
pub fn date_field_text(kind: &str) -> String {
|
||||
use deckcraft_text::datetime::{DateTime, field_text};
|
||||
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
|
||||
@@ -464,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -336,6 +336,8 @@ fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn deselect(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.painter = None;
|
||||
s.anim_painter = None;
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.take() {
|
||||
// Esc while editing text selects the shape.
|
||||
|
||||
@@ -79,7 +79,10 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
s.add_document(DocState::new(p, path, title))
|
||||
let missing_fonts = crate::fonts::missing_fonts(&p);
|
||||
let mut st = DocState::new(p, path, title);
|
||||
st.missing_fonts = missing_fonts;
|
||||
s.add_document(st)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -89,7 +92,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = open_presentation(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
|
||||
Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()}))
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -101,7 +105,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
|
||||
let doc = open_presentation(name, &data)?;
|
||||
let i = add_opened(s, name, None, doc);
|
||||
Ok(json!({"document": i}))
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
|
||||
|
||||
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
color
|
||||
),
|
||||
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
|
||||
cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case),
|
||||
cmd!(
|
||||
"format.case",
|
||||
"Change Case",
|
||||
["Home", "Font"],
|
||||
Some("Shift+F3"),
|
||||
"{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",
|
||||
has_text_or_shapes,
|
||||
case
|
||||
),
|
||||
cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),
|
||||
cmd!(
|
||||
"format.spacing",
|
||||
@@ -71,7 +79,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text_or_shapes,
|
||||
text_outline
|
||||
),
|
||||
cmd!("format.align", "Align", ["Home", "Paragraph"], None, "{align: left|center|right|justify|distributed}", has_text_or_shapes, align),
|
||||
cmd!(
|
||||
"format.align",
|
||||
"Align",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{align: left|center|right|justify|justifyLow|distributed}",
|
||||
has_text_or_shapes,
|
||||
align
|
||||
),
|
||||
cmd!(
|
||||
"format.rtl",
|
||||
"Right-to-Left Text Direction",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{on?: bool} (off = left-to-right; switching mirrors left/right alignment)",
|
||||
has_text_or_shapes,
|
||||
rtl
|
||||
),
|
||||
cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)),
|
||||
cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)),
|
||||
cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)),
|
||||
@@ -311,15 +336,23 @@ fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = str_param(p, "mode").unwrap_or("sentence").to_string();
|
||||
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = t.ordered();
|
||||
if a == b {
|
||||
return ok();
|
||||
let (mut a, mut b) = t.ordered();
|
||||
let body = super::text::body_of(st, &t).cloned().unwrap_or_default();
|
||||
let collapsed = a == b;
|
||||
if collapsed {
|
||||
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
|
||||
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
|
||||
return ok();
|
||||
}
|
||||
(a, b) = (wa, wb);
|
||||
}
|
||||
let m = mode.clone();
|
||||
super::text::edit_text(s, move |body, _| {
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
for pi in a.0..=b.0 {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
@@ -342,9 +375,23 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pos += len;
|
||||
}
|
||||
}
|
||||
if collapsed {
|
||||
t.anchor = a;
|
||||
t.caret = a;
|
||||
}
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let mode = if mode == "cycle" {
|
||||
let all: String =
|
||||
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
|
||||
"
|
||||
",
|
||||
);
|
||||
ed::next_case(&all).to_string()
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
edit_shapes(s, &json!({}), "format.case", |sh| {
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
@@ -367,12 +414,29 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"center" | "centre" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justifyLow" | "justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
};
|
||||
align_to(s, a)
|
||||
}
|
||||
|
||||
/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat
|
||||
/// at the start edge stays there.
|
||||
fn rtl(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = on_param(p, current_para(s).rtl);
|
||||
format_para(s, &move |para| {
|
||||
if para.props.rtl.unwrap_or(false) != on {
|
||||
para.props.align = match para.props.align {
|
||||
None | Some(Align::Left) => Some(Align::Right),
|
||||
Some(Align::Right) => Some(Align::Left),
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
para.props.rtl = Some(on);
|
||||
})
|
||||
}
|
||||
|
||||
fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. }));
|
||||
let on = on_param(p, Some(cur));
|
||||
|
||||
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number),
|
||||
cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time),
|
||||
cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol),
|
||||
cmd!("insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, ids?}", has_text_or_shapes, hyperlink),
|
||||
cmd!(
|
||||
"insert.hyperlink",
|
||||
"Link",
|
||||
["Insert", "Links"],
|
||||
Some("Cmd+K"),
|
||||
"{url? | slide?: index, tooltip?, text?: shown when nothing is selected, ids?}",
|
||||
has_text_or_shapes,
|
||||
hyperlink
|
||||
),
|
||||
cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd),
|
||||
cmd!(
|
||||
"chart.data",
|
||||
@@ -556,7 +564,8 @@ fn slide_number(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn date_time(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = str_param(p, "format").unwrap_or("datetime1").to_string();
|
||||
field_run(s, &f, "")
|
||||
// Saved with the date it shows now (the text kept where the date can't be computed).
|
||||
field_run(s, &f, &super::design::date_field_text(&f))
|
||||
}
|
||||
|
||||
fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -569,18 +578,20 @@ fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
let action = if let Some(u) = str_param(p, "url") {
|
||||
Action::Url { url: u.to_string() }
|
||||
// `display`: what a caret with nothing to link inserts, the address or the slide's title.
|
||||
let (action, display) = if let Some(u) = str_param(p, "url") {
|
||||
(Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string())
|
||||
} else if let Some(i) = usize_param(p, "slide") {
|
||||
let id = s.doc()?.doc.slides.get(i).map(|x| x.id).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?;
|
||||
Action::Slide { slide: id }
|
||||
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?;
|
||||
let title = slide.title();
|
||||
(Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title })
|
||||
} else {
|
||||
return Err(bad("insert.hyperlink", "missing `url` or `slide`"));
|
||||
};
|
||||
let display = str_param(p, "text").map_or(display, str::to_string);
|
||||
let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false };
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
let l = link.clone();
|
||||
return super::text::format_run(s, &move |rp| rp.link = Some(l.clone()));
|
||||
return super::text::link_text(s, link, &display);
|
||||
}
|
||||
edit_shapes(s, p, "insert.hyperlink", |sh| {
|
||||
sh.click = Some(link.clone());
|
||||
|
||||
@@ -33,6 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
poster_frame
|
||||
),
|
||||
cmd!(noundo "media.gifPlay", "Play/Pause GIF", [], None, "{id?, play?: bool (default: toggle)} → {id, playing}", has_doc, gif_play),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -148,6 +149,34 @@ fn poster_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id, "ms": ms}))
|
||||
}
|
||||
|
||||
/// Pause or play an animated GIF picture (`id` / `ids` / the selection, else the only one on the
|
||||
/// slide). The UI host keeps its clock: a paused GIF holds its frame and resumes from it.
|
||||
fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "media.gifPlay";
|
||||
let st = s.doc()?;
|
||||
let is_gif = |id: ShapeId| {
|
||||
let Some(ShapeKind::Picture { fill }) = st.shape(id).map(|sh| &sh.kind) else { return false };
|
||||
st.doc.media(fill.media).and_then(|m| crate::render::gif_frame(&m.data, &fill.adjust, 0.0)).is_some()
|
||||
};
|
||||
let ids = targets(s, p)?;
|
||||
let id = match ids.iter().copied().find(|id| is_gif(*id)) {
|
||||
Some(id) => id,
|
||||
None if !ids.is_empty() && (p.get("id").is_some() || p.get("ids").is_some()) => return Err(bad(cmd, "not an animated GIF")),
|
||||
None => match st.shapes().iter().map(|sh| sh.id).filter(|id| is_gif(*id)).collect::<Vec<_>>().as_slice() {
|
||||
[id] => *id,
|
||||
[] => return Err(bad(cmd, "no animated GIF on this slide")),
|
||||
_ => return Err(bad(cmd, "select an animated GIF, or pass `id`")),
|
||||
},
|
||||
};
|
||||
let play = bool_param(p, "play").unwrap_or_else(|| s.gif_paused.contains(&id));
|
||||
if play {
|
||||
s.gif_paused.remove(&id);
|
||||
} else {
|
||||
s.gif_paused.insert(id);
|
||||
}
|
||||
Ok(json!({"id": id, "playing": play}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -208,6 +237,27 @@ mod tests {
|
||||
assert!(s.execute("media.play", &json!({"id": sh["id"]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_play_pauses_and_resumes_an_animated_gif() {
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
let frame = |c: [u8; 4]| Frame::from_parts(RgbaImage::from_pixel(8, 8, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
|
||||
let mut gif = Vec::new();
|
||||
image::codecs::gif::GifEncoder::new(&mut gif).encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])]).expect("gif");
|
||||
let mut s = Session::with_new();
|
||||
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
|
||||
assert!(s.execute("media.gifPlay", &json!({})).is_err(), "no GIF yet");
|
||||
let r = s.execute("insert.picture", &json!({"name": "a.gif", "data": base64_encode(&gif)})).expect("insert");
|
||||
let id = ShapeId(r["id"].as_u64().expect("id") as u32);
|
||||
let r = s.execute("media.gifPlay", &json!({})).expect("toggle");
|
||||
assert_eq!(r["playing"], false);
|
||||
assert!(s.gif_paused.contains(&id));
|
||||
let r = s.execute("media.gifPlay", &json!({"id": id, "play": true})).expect("play");
|
||||
assert_eq!(r["playing"], true);
|
||||
assert!(s.gif_paused.is_empty());
|
||||
let sh = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 10, 10]})).expect("shape");
|
||||
assert!(s.execute("media.gifPlay", &json!({"id": sh["id"]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wma_inserts_but_reports_unplayable() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod inspect;
|
||||
pub mod media;
|
||||
pub mod merge;
|
||||
pub mod review;
|
||||
pub mod selection;
|
||||
pub mod shape;
|
||||
pub mod slide;
|
||||
pub mod table;
|
||||
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(insert::specs());
|
||||
v.extend(shape::specs());
|
||||
v.extend(arrange::specs());
|
||||
v.extend(selection::specs());
|
||||
v.extend(merge::specs());
|
||||
v.extend(text::specs());
|
||||
v.extend(format::specs());
|
||||
@@ -246,12 +248,25 @@ pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result<deckcraft_model::Shap
|
||||
s.doc()?.shape(id).cloned().ok_or_else(|| EngineError::Other(format!("no shape {id}")))
|
||||
}
|
||||
|
||||
/// Effective box of a shape (placeholders inherit theirs).
|
||||
/// Effective box of a shape (placeholders inherit theirs). A table's box is at least as tall as its
|
||||
/// rows are drawn (rows grow to fit their text), so handles and hit tests match what is shown.
|
||||
pub fn xfrm_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
|
||||
if let Some(x) = shape.xfrm {
|
||||
return x;
|
||||
let mut x = shape.xfrm.unwrap_or_else(|| ctx_xfrm(doc, sel, shape));
|
||||
if let deckcraft_model::ShapeKind::Table(t) = &shape.kind {
|
||||
let drawn: f64 = table_drawn_heights(doc, sel, t).iter().sum();
|
||||
if drawn.is_finite() && drawn > x.h {
|
||||
x.h = drawn;
|
||||
}
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Row heights of table `t` as drawn: a stored height is a minimum, rows grow to fit their text.
|
||||
pub fn table_drawn_heights(doc: &deckcraft_model::Presentation, sel: &crate::Selection, t: &deckcraft_model::Table) -> Vec<f64> {
|
||||
match text::ctx_for(doc, sel) {
|
||||
Some(ctx) => deckcraft_render::table_row_heights(&ctx, t, &deckcraft_text::NoFields),
|
||||
None => t.rows.iter().map(|r| r.height).collect(),
|
||||
}
|
||||
ctx_xfrm(doc, sel, shape)
|
||||
}
|
||||
|
||||
fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
|
||||
@@ -296,6 +311,18 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut
|
||||
})
|
||||
}
|
||||
|
||||
/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop):
|
||||
/// locked targets are left out, and when every target is locked nothing changes.
|
||||
pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let free: Vec<u32> = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect();
|
||||
if !ids.is_empty() && free.is_empty() {
|
||||
return Err(bad(cmd, "the object is locked"));
|
||||
}
|
||||
edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f)
|
||||
}
|
||||
|
||||
pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec<deckcraft_model::Shape>> {
|
||||
match sel.target {
|
||||
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are
|
||||
//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`,
|
||||
//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop).
|
||||
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"selection.select",
|
||||
"Select",
|
||||
["Selection Pane"],
|
||||
None,
|
||||
"{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown",
|
||||
has_slide,
|
||||
select
|
||||
),
|
||||
cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all),
|
||||
cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Selection pane's click. Objects inside groups and top-level objects can't be selected
|
||||
/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden
|
||||
/// object from the list shows it again.
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
|
||||
let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false));
|
||||
let st = s.doc()?;
|
||||
let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some());
|
||||
let mut valid: Vec<ShapeId> = ids.iter().copied().filter(|i| in_group(i).is_some()).collect();
|
||||
if valid.is_empty() {
|
||||
return Err(bad("selection.select", "no such shape"));
|
||||
}
|
||||
let kind = valid.first().and_then(in_group);
|
||||
valid.retain(|i| in_group(i) == kind);
|
||||
let keep: Vec<ShapeId> = if toggle || add {
|
||||
let current = st.selection.shapes.clone();
|
||||
if current.first().is_some_and(|c| in_group(c) != kind) {
|
||||
// Can't mix objects in groups with top-level ones: nothing changes.
|
||||
return Ok(json!({"selected": current}));
|
||||
}
|
||||
current
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut next = keep;
|
||||
for i in &valid {
|
||||
match next.iter().position(|x| x == i) {
|
||||
Some(k) if toggle => {
|
||||
next.remove(k);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => next.push(*i),
|
||||
}
|
||||
}
|
||||
let reveal: Vec<ShapeId> = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect();
|
||||
if reveal.is_empty() {
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
})?;
|
||||
} else {
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?;
|
||||
for id in &reveal {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||
sh.hidden = false;
|
||||
}
|
||||
}
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"selected": s.doc()?.selection.shapes}))
|
||||
}
|
||||
|
||||
fn show_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn any_hidden(v: &[Shape]) -> bool {
|
||||
let mut found = false;
|
||||
deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden);
|
||||
found
|
||||
}
|
||||
if !any_hidden(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
fn rec(v: &mut [Shape], depth: usize) {
|
||||
for sh in v {
|
||||
sh.hidden = false;
|
||||
if depth < 64
|
||||
&& let Some(c) = sh.children_mut()
|
||||
{
|
||||
rec(c, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn hide_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
if s.doc()?.shapes().iter().all(|x| x.hidden) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? {
|
||||
sh.hidden = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Selection pane: reorder, groups, hide/show, lock and undo.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::*;
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
|
||||
fn deck() -> (Session, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() {
|
||||
let x = 20.0 + 110.0 * i as f64;
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
|
||||
ids.extend([h, g]);
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// The tree as text, back to front: `A B C G[D H[E F]]`.
|
||||
fn tree(s: &Session) -> String {
|
||||
fn rec(v: &[deckcraft_model::Shape]) -> String {
|
||||
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
rec(s.doc().unwrap().shapes())
|
||||
}
|
||||
|
||||
/// (hidden, locked)
|
||||
fn flags(s: &Session, id: u32) -> (bool, bool) {
|
||||
let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap();
|
||||
(sh.hidden, sh.locked)
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> Vec<u32> {
|
||||
s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect()
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
fn bx(s: &mut Session, id: u32) -> Value {
|
||||
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bring_forward_and_send_backward_stay_inside_the_group() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
// A child moves among its siblings only and stops at the group's front.
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H");
|
||||
s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G");
|
||||
// Steps that changed nothing left no undo step.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_is_the_drag_and_drop_among_siblings() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]);
|
||||
// Drag A to the front of the slide (an index past the end clamps).
|
||||
assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3);
|
||||
assert_eq!(tree(&s), "B C G[D H[E F]] A");
|
||||
s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[D H[E F]] B C A");
|
||||
// A child is dragged within its group: it never leaves it.
|
||||
s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[F E] D] B C A");
|
||||
// Dropping where it already is changes nothing (no undo step).
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// One undo step per drop.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err());
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eye_show_all_and_hide_all() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]);
|
||||
s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap();
|
||||
assert!(flags(&s, b).0);
|
||||
// Hiding a group hides only the group; its children keep their own flags.
|
||||
s.execute("shape.visible", &json!({"id": g})).unwrap();
|
||||
assert!(flags(&s, g).0 && !flags(&s, d).0);
|
||||
s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap();
|
||||
// Hide All hides the top level only.
|
||||
s.execute("selection.hideAll", &json!({})).unwrap();
|
||||
assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0);
|
||||
// Show All shows everything, children included.
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
for id in &ids {
|
||||
assert!(!flags(&s, *id).0, "{id} still hidden");
|
||||
}
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, a).0 && flags(&s, e).0);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_cascades_through_groups_and_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(flags(&s, a).1);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(!flags(&s, a).1, "toggles");
|
||||
// Locking a group locks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap();
|
||||
assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1);
|
||||
// A child can be unlocked on its own.
|
||||
s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap();
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1);
|
||||
// Unlocking the group unlocks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g})).unwrap();
|
||||
assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, g).1 && !flags(&s, e).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
let before = bx(&mut s, a);
|
||||
for (cmd, p) in [
|
||||
("shape.move", json!({"ids": [a], "dx": 10})),
|
||||
("shape.resize", json!({"ids": [a], "w": 300})),
|
||||
("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})),
|
||||
("shape.rotate", json!({"ids": [a], "deg": 45})),
|
||||
("shape.flip", json!({"ids": [a], "axis": "vertical"})),
|
||||
("arrange.align", json!({"ids": [a], "edge": "right"})),
|
||||
("shape.change", json!({"ids": [a], "preset": "ellipse"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape");
|
||||
}
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
// Mixed selections: the unlocked ones move, the locked one stays.
|
||||
let bb = bx(&mut s, b);
|
||||
s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap();
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0);
|
||||
// Formatting and the name still change.
|
||||
s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap();
|
||||
s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor");
|
||||
// The arrow keys don't nudge it.
|
||||
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
|
||||
let _ = s.key("ArrowRight", Mods::default());
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
|
||||
let (mut s, ids) = deck();
|
||||
let a = ids[0];
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
// A: x 20..120, y 50..130.
|
||||
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
|
||||
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
|
||||
s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["x"], 20.0);
|
||||
// No resize handle: pressing on the SE corner and dragging doesn't size it.
|
||||
s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["w"], 100.0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_from_the_pane() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]);
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b, c]);
|
||||
s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c, a, b]);
|
||||
// A child inside a group; children across nesting levels go together.
|
||||
s.execute("selection.select", &json!({"id": d})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d]);
|
||||
s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
// ...but not with top-level objects: Ctrl+click on one is ignored.
|
||||
s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
s.execute("selection.select", &json!({"id": a})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
// Picking makes no undo step...
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// ...unless it shows a hidden object again.
|
||||
s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": c})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
assert!(!flags(&s, c).0, "picking a hidden object shows it");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, c).0);
|
||||
assert!(s.execute("selection.select", &json!({"id": 4242})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_from_the_pane_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let e = ids[4];
|
||||
s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle");
|
||||
undo(&mut s);
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E");
|
||||
}
|
||||
@@ -76,7 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
|
||||
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
|
||||
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
|
||||
cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock),
|
||||
cmd!(
|
||||
"shape.lock",
|
||||
"Lock",
|
||||
["Selection Pane"],
|
||||
None,
|
||||
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
|
||||
has_selection,
|
||||
lock
|
||||
),
|
||||
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
||||
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
|
||||
cmd!(
|
||||
@@ -113,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
|
||||
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
|
||||
edit_shapes(s, p, "shape.move", |sh| {
|
||||
edit_geometry(s, p, "shape.move", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
|
||||
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
|
||||
@@ -125,7 +133,7 @@ fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
|
||||
let lock = bool_or(p, "lockAspect", false);
|
||||
edit_shapes(s, p, "shape.resize", |sh| {
|
||||
edit_geometry(s, p, "shape.resize", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
||||
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
@@ -154,7 +162,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, p, "shape.setBounds", |sh| {
|
||||
edit_geometry(s, p, "shape.setBounds", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = f64_param(p, "x").unwrap_or(x.x);
|
||||
x.y = f64_param(p, "y").unwrap_or(x.y);
|
||||
@@ -167,7 +175,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||||
edit_shapes(s, p, "shape.rotate", |sh| {
|
||||
edit_geometry(s, p, "shape.rotate", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
|
||||
x.rot = ((r % 360.0) + 360.0) % 360.0;
|
||||
@@ -178,7 +186,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||
edit_shapes(s, p, "shape.flip", |sh| {
|
||||
edit_geometry(s, p, "shape.flip", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
x.flip_v = !x.flip_v;
|
||||
@@ -193,7 +201,7 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||
edit_shapes(s, p, "shape.adjust", |sh| {
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||||
while adj.len() <= i {
|
||||
@@ -211,7 +219,7 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
||||
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
||||
edit_shapes(s, p, "shape.change", |sh| {
|
||||
edit_geometry(s, p, "shape.change", |sh| {
|
||||
sh.geom = Geom::preset(info.name);
|
||||
Ok(())
|
||||
})
|
||||
@@ -570,14 +578,30 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated,
|
||||
/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it
|
||||
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = bool_param(p, "locked");
|
||||
edit_shapes(s, p, "shape.lock", |sh| {
|
||||
sh.locked = v.unwrap_or(!sh.locked);
|
||||
let to = v.unwrap_or(!sh.locked);
|
||||
set_locked(sh, to, 0);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) {
|
||||
sh.locked = to;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if let Some(children) = sh.children_mut() {
|
||||
for c in children {
|
||||
set_locked(c, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
|
||||
@@ -595,7 +619,7 @@ fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
|
||||
edit_shapes(s, p, "picture.crop", |sh| {
|
||||
edit_geometry(s, p, "picture.crop", |sh| {
|
||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
for (i, v) in c.iter().enumerate() {
|
||||
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
|
||||
|
||||
+275
-32
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::edit::{self as ed, Pos};
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{AutoFit, Run, RunKind, RunProps, TextBody};
|
||||
use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody};
|
||||
use deckcraft_model::{Presentation, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -17,7 +17,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
|
||||
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
|
||||
cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete),
|
||||
// The input method's composition: shown in place, an undo step only once committed.
|
||||
CommandSpec {
|
||||
id: "text.compose",
|
||||
label: "Compose Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it",
|
||||
enabled: has_text,
|
||||
run: compose,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!(
|
||||
noundo "text.commit",
|
||||
"Commit Composition",
|
||||
[],
|
||||
None,
|
||||
"{text} — the input method's result replaces its composition, as one undo step",
|
||||
has_text,
|
||||
commit
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
|
||||
cmd!(
|
||||
"text.delete",
|
||||
"Delete Text",
|
||||
[],
|
||||
Some("Backspace"),
|
||||
"{dir?: backward|forward|wordBackward|wordForward} — the word directions are Ctrl+Backspace/Delete: they work from the start of a selection without deleting it",
|
||||
has_text,
|
||||
delete
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
||||
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
||||
cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word),
|
||||
@@ -144,6 +175,35 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
|
||||
))
|
||||
}
|
||||
|
||||
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
|
||||
/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is
|
||||
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and
|
||||
/// table cells, which aren't laid out on the slide here.
|
||||
pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> {
|
||||
if t.notes || t.cell.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||
let mut body = sh.text.clone().unwrap_or_default();
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
||||
}
|
||||
let l = deckcraft_text::layout(
|
||||
&ctx,
|
||||
sh,
|
||||
&body,
|
||||
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
);
|
||||
// After End on a wrapped line the caret is drawn at that line's end.
|
||||
let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||
let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;
|
||||
let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);
|
||||
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
|
||||
}
|
||||
|
||||
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
@@ -184,7 +244,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false });
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
|
||||
})?;
|
||||
Ok(json!({"id": id, "at": [at.0, at.1]}))
|
||||
}
|
||||
@@ -228,7 +288,7 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
let pos = edit_text(s, |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = ed::delete(body, a, b);
|
||||
let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { None });
|
||||
let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) });
|
||||
let c = ed::insert(body, at, &text, props);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
@@ -238,6 +298,20 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
Ok(json!({"caret": [pos.0, pos.1]}))
|
||||
}
|
||||
|
||||
/// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked
|
||||
/// run, the props to type with are the run's without the link. `None` elsewhere.
|
||||
fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option<RunProps> {
|
||||
let mut props = ed::props_at(body, at);
|
||||
props.link.as_ref()?;
|
||||
let len = body.paragraphs.get(at.0).map_or(0, |p| p.text().chars().count());
|
||||
// props_at(c + 1) is the props of the character at c.
|
||||
if at.1 < len && ed::props_at(body, (at.0, at.1 + 1)).link == props.link {
|
||||
return None;
|
||||
}
|
||||
props.link = None;
|
||||
Some(props)
|
||||
}
|
||||
|
||||
const AUTOCORRECT: &[(&str, &str)] = &[
|
||||
("teh", "the"),
|
||||
("adn", "and"),
|
||||
@@ -326,6 +400,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result<V
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own).
|
||||
fn ime_text(s: &str) -> String {
|
||||
s.chars().filter(|c| !c.is_control()).collect()
|
||||
}
|
||||
|
||||
/// Is the composition still where it was put (the paragraph holds all of it)?
|
||||
fn composition_fits(st: &DocState, c: &crate::Composition) -> bool {
|
||||
let Some(t) = &st.selection.text else { return false };
|
||||
let Some(body) = body_of(st, t) else { return false };
|
||||
body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len())
|
||||
}
|
||||
|
||||
/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection)
|
||||
/// or replace the composition with the new preedit. An empty preedit cancels: the presentation
|
||||
/// goes back to how it was before the composition began.
|
||||
fn compose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?);
|
||||
let n = text.chars().count();
|
||||
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||
(x < y).then_some((x.min(n), y.min(n)))
|
||||
});
|
||||
let current = s.doc()?.composition.clone();
|
||||
// A composition whose text was changed under it is kept as it is; a new one starts.
|
||||
let current = match current {
|
||||
Some(c) if !composition_fits(s.doc()?, &c) => {
|
||||
s.end_composition();
|
||||
None
|
||||
}
|
||||
c => c,
|
||||
};
|
||||
match current {
|
||||
None if text.is_empty() => Ok(json!({"composing": false})),
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
let (before, before_selection) = (st.doc.clone(), st.selection.clone());
|
||||
let (start, props) = edit_text(s, |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = ed::delete(body, a, b);
|
||||
let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) };
|
||||
let c = ed::insert(body, at, &text, props.clone());
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
(at, props)
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
Some(c) if text.is_empty() => {
|
||||
let st = s.doc_mut()?;
|
||||
st.doc = c.before;
|
||||
st.selection = c.before_selection;
|
||||
st.composition = None;
|
||||
st.revision += 1;
|
||||
Ok(json!({"composing": false}))
|
||||
}
|
||||
Some(c) => {
|
||||
let (start, len, props) = (c.start, c.len, c.props.clone());
|
||||
edit_text(s, |body, t| {
|
||||
ed::delete(body, start, (start.0, start.1 + len));
|
||||
let e = ed::insert(body, start, &text, props);
|
||||
t.anchor = e;
|
||||
t.caret = e;
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.commit`: the input method's result replaces the composition (or is typed at the
|
||||
/// insertion point when there is none) and the whole composition becomes one undo step.
|
||||
fn commit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?);
|
||||
if s.doc()?.composition.is_some() || !text.is_empty() {
|
||||
compose(s, &json!({"text": text}))?;
|
||||
}
|
||||
s.end_composition();
|
||||
let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default();
|
||||
Ok(json!({"caret": [caret.0, caret.1]}))
|
||||
}
|
||||
|
||||
/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to,
|
||||
/// thick)` per line, thick under the part being converted. Empty without a composition or when
|
||||
/// the edited text isn't laid out on the slide (notes, table cells).
|
||||
pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
|
||||
let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] };
|
||||
let Some(sh) = st.shape(t.shape) else { return vec![] };
|
||||
let Some(l) = layout_for(st, t) else { return vec![] };
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch };
|
||||
let mut out = vec![];
|
||||
let mut add = |a: usize, b: usize, thick: bool| {
|
||||
for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) {
|
||||
out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick));
|
||||
}
|
||||
};
|
||||
add(0, c.len, false);
|
||||
if let Some((a, b)) = c.active {
|
||||
add(a, b.min(c.len), true);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
|
||||
insert_text(s, &text, None)
|
||||
@@ -335,13 +515,15 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
let dir = dir.to_string();
|
||||
edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = if a != b {
|
||||
let at = if dir == "wordBackward" || dir == "wordForward" {
|
||||
// Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it
|
||||
// (PowerPoint for Windows).
|
||||
ed::delete_word(body, a, dir == "wordForward")
|
||||
} else if a != b {
|
||||
ed::delete(body, a, b)
|
||||
} else {
|
||||
let other = match dir.as_str() {
|
||||
"forward" => ed::char_move(body, a, true),
|
||||
"wordBackward" => ed::word_move(body, a, false),
|
||||
"wordForward" => ed::word_move(body, a, true),
|
||||
_ => {
|
||||
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
||||
if a.1 == 0
|
||||
@@ -368,7 +550,16 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||
}
|
||||
|
||||
/// Is paragraph `para` right-to-left (its lines run from the right)?
|
||||
fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool {
|
||||
match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) {
|
||||
Some(line) => line.rtl,
|
||||
None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_text::Affinity;
|
||||
let to = str_param(p, "to").unwrap_or("right").to_string();
|
||||
let extend = bool_or(p, "extend", false);
|
||||
let st = s.doc()?;
|
||||
@@ -377,27 +568,44 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let layout = layout_for(st, &t);
|
||||
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
|
||||
let caret = t.caret;
|
||||
let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream };
|
||||
// Left and right are on screen: in a right-to-left paragraph left goes forward in the text.
|
||||
let rtl = para_rtl(layout.as_ref(), &body, caret.0);
|
||||
let to = match (to.as_str(), rtl) {
|
||||
("left", true) => "right",
|
||||
("right", true) => "left",
|
||||
("wordLeft", true) => "wordRight",
|
||||
("wordRight", true) => "wordLeft",
|
||||
(x, _) => x,
|
||||
}
|
||||
.to_string();
|
||||
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
|
||||
let mut upstream = false;
|
||||
let new = match to.as_str() {
|
||||
"left" if t.is_range() && !extend => collapse_to(true),
|
||||
"right" if t.is_range() && !extend => collapse_to(false),
|
||||
"left" => ed::char_move(&body, caret, false),
|
||||
"right" => ed::char_move(&body, caret, true),
|
||||
"wordLeft" => ed::word_move(&body, caret, false),
|
||||
"wordRight" => ed::word_move(&body, caret, true),
|
||||
// Ctrl+arrows with a selection go on from its start (left) or end (right).
|
||||
"wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false),
|
||||
"wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true),
|
||||
"paraStart" => (caret.0, 0),
|
||||
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
"paraUp" => ed::para_up(&body, caret),
|
||||
"paraDown" => ed::para_down(&body, caret),
|
||||
"start" => (0, 0),
|
||||
"end" => ed::end(&body),
|
||||
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
|
||||
Some(l) => {
|
||||
let x = l.caret(lp(caret)).map(|c| c.0).unwrap_or(0.0);
|
||||
let r = match to.as_str() {
|
||||
"up" => l.vertical(lp(caret), false, x),
|
||||
"down" => l.vertical(lp(caret), true, x),
|
||||
"lineStart" => l.line_bounds(lp(caret)).0,
|
||||
_ => l.line_bounds(lp(caret)).1,
|
||||
let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0);
|
||||
let (r, a) = match to.as_str() {
|
||||
"up" => l.vertical_affine(lp(caret), aff, false, x),
|
||||
"down" => l.vertical_affine(lp(caret), aff, true, x),
|
||||
"lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream),
|
||||
// End stays on its line: after a soft wrap the caret sits at the line's end.
|
||||
_ => l.line_end_caret(lp(caret), aff),
|
||||
};
|
||||
upstream = a == Affinity::Upstream;
|
||||
(r.para, r.ch)
|
||||
}
|
||||
None => match to.as_str() {
|
||||
@@ -413,12 +621,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.caret = new;
|
||||
tt.line_end = if upstream { Some(new) } else { None };
|
||||
if !extend {
|
||||
tt.anchor = new;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"caret": [new.0, new.1]}))
|
||||
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -465,11 +674,14 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
let body = body_of(st, &t);
|
||||
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
t.caret = (at.0, n);
|
||||
t.caret = end;
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
@@ -535,21 +747,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let body = body_of(st, &t).cloned().unwrap_or_default();
|
||||
let (mut a, mut b) = t.ordered();
|
||||
if a == b {
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let inside = wa.1 < a.1 && a.1 < wb.1;
|
||||
if inside {
|
||||
a = wa;
|
||||
b = wb;
|
||||
// Don't include the trailing space.
|
||||
let line: Vec<char> = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default();
|
||||
while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') {
|
||||
b.1 -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (a, b) = format_range(st, &t);
|
||||
let collapsed = a == b;
|
||||
edit_text(s, |body, _| {
|
||||
if collapsed {
|
||||
@@ -591,6 +789,51 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
ok()
|
||||
}
|
||||
|
||||
/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in
|
||||
/// (without its trailing space). Empty when the caret is at a word boundary.
|
||||
fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) {
|
||||
let body = body_of(st, t).cloned().unwrap_or_default();
|
||||
let (mut a, mut b) = t.ordered();
|
||||
if a == b {
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let inside = wa.1 < a.1 && a.1 < wb.1;
|
||||
if inside {
|
||||
a = wa;
|
||||
b = wb;
|
||||
// Don't include the trailing space.
|
||||
let line: Vec<char> = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default();
|
||||
while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') {
|
||||
b.1 -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Link the text being edited: the selection or the word at the caret, as `format_run` would.
|
||||
/// With nothing to link (caret at a word boundary, empty paragraph) `display` is inserted at the
|
||||
/// caret as linked text in the surrounding formatting, as PowerPoint's Insert Hyperlink does.
|
||||
pub(crate) fn link_text(s: &mut Session, link: Hyperlink, display: &str) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let Some(t) = st.selection.text.clone() else { return Err(bad("insert.hyperlink", "not editing text")) };
|
||||
let (a, b) = format_range(st, &t);
|
||||
if a != b {
|
||||
return format_run(s, &move |rp| rp.link = Some(link.clone()));
|
||||
}
|
||||
if display.is_empty() {
|
||||
return Err(bad("insert.hyperlink", "nothing to link: select text or give `text`"));
|
||||
}
|
||||
let pos = edit_text(s, |body, t| {
|
||||
let mut props = ed::props_at(body, a);
|
||||
props.link = Some(link);
|
||||
let c = ed::insert(body, a, display, Some(props));
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
c
|
||||
})?;
|
||||
Ok(json!({"caret": [pos.0, pos.1]}))
|
||||
}
|
||||
|
||||
/// Apply paragraph changes to the paragraphs touched by the selection (or all paragraphs of the
|
||||
/// selected shapes).
|
||||
pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) -> Result<Value> {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
|
||||
//! draws in a stand-in; see `FontDb::document_face`).
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Bullet, TextBody};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
|
||||
/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive).
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for family in used_fonts(p) {
|
||||
if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
out.push(family);
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|f| f.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
/// The families the slides' text runs and bullets are drawn in.
|
||||
fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut add = |f: String| {
|
||||
let f = f.trim().to_string();
|
||||
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
|
||||
out.push(f);
|
||||
}
|
||||
};
|
||||
for slide in &p.slides {
|
||||
let Some(ctx) = Ctx::for_slide(p, slide) else { continue };
|
||||
deckcraft_model::walk(&slide.shapes, &mut |s, _| {
|
||||
if let Some(body) = &s.text {
|
||||
body_fonts(&ctx, s, body, &mut add);
|
||||
}
|
||||
if let ShapeKind::Table(t) = &s.kind {
|
||||
for cell in t.rows.iter().flat_map(|r| &r.cells) {
|
||||
body_fonts(&ctx, s, &cell.text, &mut add);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String)) {
|
||||
for para in &body.paragraphs {
|
||||
let has_text = para.runs.iter().any(|r| !r.text.trim().is_empty());
|
||||
if !has_text {
|
||||
continue;
|
||||
}
|
||||
for run in para.runs.iter().filter(|r| !r.text.trim().is_empty()) {
|
||||
let r = resolve::run(ctx, s, para, &run.props);
|
||||
add(resolve::font_family(ctx, &r));
|
||||
}
|
||||
let pp = resolve::para(ctx, s, para);
|
||||
if matches!(pp.bullet, Some(Bullet::Char { .. }))
|
||||
&& let Some(f) = pp.bullet_font.as_deref()
|
||||
{
|
||||
add(ctx.master.theme.font(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
-11
@@ -20,28 +20,49 @@ pub fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
|
||||
k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt
|
||||
}
|
||||
|
||||
/// A key name in one spelling: lower case, `ArrowLeft` → `left`, and egui's names for
|
||||
/// punctuation keys (`Period`, `CloseBracket`…) → the character, so `Cmd+Shift+.` matches the
|
||||
/// key egui reports for Ctrl+Shift+> and `Cmd+]` the one for Ctrl+].
|
||||
pub fn key_token(key: &str) -> String {
|
||||
let k = key.to_ascii_lowercase();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
match k {
|
||||
"period" | ">" => ".",
|
||||
"comma" | "<" => ",",
|
||||
"openbracket" | "{" => "[",
|
||||
"closebracket" | "}" => "]",
|
||||
"equals" => "=",
|
||||
"minus" => "-",
|
||||
"plus" => "+",
|
||||
"slash" => "/",
|
||||
"backslash" => "\\",
|
||||
"semicolon" => ";",
|
||||
"quote" => "'",
|
||||
"backtick" => "`",
|
||||
other => other,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Handle one key press. `key` is a name (`Enter`, `Escape`, `Backspace`, `Delete`, `Tab`,
|
||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||
/// Returns `{handled: bool, …}`.
|
||||
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
||||
self.end_composition();
|
||||
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key.to_ascii_lowercase();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
let k = key_token(key);
|
||||
let k = k.as_str();
|
||||
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if editing && let Some((id, params)) = self.text_key(k, mods) {
|
||||
let r = self.execute(id, ¶ms)?;
|
||||
return Ok(json!({"handled": true, "command": id, "result": r}));
|
||||
}
|
||||
if editing && !mods.cmd {
|
||||
let r = match k {
|
||||
"left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))),
|
||||
"right" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordRight" } else { "right" }, "extend": mods.shift}))),
|
||||
"up" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraStart" } else { "up" }, "extend": mods.shift}))),
|
||||
"down" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraEnd" } else { "down" }, "extend": mods.shift}))),
|
||||
"home" => Some(self.execute("text.move", &json!({"to": "lineStart", "extend": mods.shift}))),
|
||||
"end" => Some(self.execute("text.move", &json!({"to": "lineEnd", "extend": mods.shift}))),
|
||||
"backspace" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordBackward" } else { "backward" }}))),
|
||||
"delete" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordForward" } else { "forward" }}))),
|
||||
"enter" | "return" => Some(self.execute("text.insert", &json!({"text": if mods.shift { "\u{b}" } else { "\n" }}))),
|
||||
"escape" | "esc" => Some(self.execute("text.exit", &json!({}))),
|
||||
"tab" => {
|
||||
@@ -116,6 +137,10 @@ impl Session {
|
||||
}
|
||||
// Shortcuts from the registry.
|
||||
let key_name = match k {
|
||||
"left" => "Left",
|
||||
"right" => "Right",
|
||||
"up" => "Up",
|
||||
"down" => "Down",
|
||||
"enter" | "return" => "Enter",
|
||||
"escape" | "esc" => "Escape",
|
||||
"delete" => "Delete",
|
||||
@@ -126,7 +151,7 @@ impl Session {
|
||||
"end" => "End",
|
||||
"tab" => "Tab",
|
||||
"space" | " " => "Space",
|
||||
_ => key,
|
||||
_ => k,
|
||||
};
|
||||
let found = crate::command_specs().iter().find(|c| c.shortcut.is_some_and(|s| shortcut_matches(s, key_name, mods))).map(|c| c.id);
|
||||
if let Some(id) = found {
|
||||
@@ -136,6 +161,50 @@ impl Session {
|
||||
Ok(json!({"handled": false}))
|
||||
}
|
||||
|
||||
/// The text-editing command for a navigation or deletion key while editing text, by
|
||||
/// platform: Ctrl (Windows, Linux) or Option (Mac) moves and deletes by word; Ctrl+Up/Down
|
||||
/// moves by paragraph; Ctrl+Home/End (Cmd+Up/Down on the Mac) go to the ends of the text and
|
||||
/// Cmd+Left/Right on the Mac to the ends of the line. Shift extends the selection.
|
||||
fn text_key(&self, k: &str, mods: Mods) -> Option<(&'static str, Value)> {
|
||||
let word = if self.mac_keys { mods.alt && !mods.cmd } else { mods.cmd && !mods.alt };
|
||||
let mac_cmd = self.mac_keys && mods.cmd && !mods.alt;
|
||||
if mods.cmd && !mods.alt && !mods.shift {
|
||||
// Ctrl+] / Ctrl+[ grow and shrink the font, like Ctrl+Shift+> / <.
|
||||
match k {
|
||||
"]" => return Some(("format.grow", json!({}))),
|
||||
"[" => return Some(("format.shrink", json!({}))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let plain = !mods.cmd && !mods.alt;
|
||||
let to = match k {
|
||||
"left" if word => "wordLeft",
|
||||
"right" if word => "wordRight",
|
||||
"left" if mac_cmd => "lineStart",
|
||||
"right" if mac_cmd => "lineEnd",
|
||||
"left" if plain => "left",
|
||||
"right" if plain => "right",
|
||||
"up" if mac_cmd => "start",
|
||||
"down" if mac_cmd => "end",
|
||||
"up" if word && self.mac_keys => "paraStart",
|
||||
"down" if word && self.mac_keys => "paraEnd",
|
||||
"up" if word => "paraUp",
|
||||
"down" if word => "paraDown",
|
||||
"up" if plain => "up",
|
||||
"down" if plain => "down",
|
||||
"home" if mods.cmd && !mods.alt => "start",
|
||||
"end" if mods.cmd && !mods.alt => "end",
|
||||
"home" if plain => "lineStart",
|
||||
"end" if plain => "lineEnd",
|
||||
"backspace" if word => return Some(("text.delete", json!({"dir": "wordBackward"}))),
|
||||
"delete" if word => return Some(("text.delete", json!({"dir": "wordForward"}))),
|
||||
"backspace" if plain => return Some(("text.delete", json!({"dir": "backward"}))),
|
||||
"delete" if plain => return Some(("text.delete", json!({"dir": "forward"}))),
|
||||
_ => return None,
|
||||
};
|
||||
Some(("text.move", json!({"to": to, "extend": mods.shift})))
|
||||
}
|
||||
|
||||
/// Type text: into the text being edited, or (a shape selected) start editing it.
|
||||
pub fn type_text(&mut self, text: &str) -> Result<Value> {
|
||||
let st = self.doc()?;
|
||||
@@ -151,6 +220,45 @@ impl Session {
|
||||
self.execute("text.insert", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// An input method's preedit (#101). On a selected shape that isn't being edited, the first
|
||||
/// one starts editing its text and replaces it, as typing does ([`Session::type_text`]).
|
||||
/// `active` is the part being converted, in characters of `text`.
|
||||
pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result<Value> {
|
||||
if !self.start_typing(text)? {
|
||||
return Ok(json!({"handled": false}));
|
||||
}
|
||||
let mut p = json!({"text": text});
|
||||
if let Some((a, b)) = active {
|
||||
p["active"] = json!([a, b]);
|
||||
}
|
||||
self.execute("text.compose", &p)
|
||||
}
|
||||
|
||||
/// An input method's result (#101): replaces the composition as one undo step.
|
||||
pub fn commit_text(&mut self, text: &str) -> Result<Value> {
|
||||
if !self.start_typing(text)? {
|
||||
return Ok(json!({"handled": false}));
|
||||
}
|
||||
self.execute("text.commit", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
|
||||
/// starts with everything selected. False when there is nowhere to type (or nothing to type
|
||||
/// and nothing being edited).
|
||||
fn start_typing(&mut self, text: &str) -> Result<bool> {
|
||||
let st = self.doc()?;
|
||||
if st.selection.text.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) };
|
||||
if st.selection.shapes.len() != 1 || text.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.execute("text.edit", &json!({"id": id}))?;
|
||||
self.execute("text.selectAll", &json!({}))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
|
||||
let st = self.doc()?;
|
||||
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
@@ -67,6 +68,10 @@ pub struct TextSel {
|
||||
/// Editing the slide's speaker notes (shape is ignored).
|
||||
#[serde(default)]
|
||||
pub notes: bool,
|
||||
/// The caret position that sits at the end of a wrapped line (after End) rather than at the
|
||||
/// start of the next line. Ignored once the caret moves anywhere else.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub line_end: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl TextSel {
|
||||
@@ -76,6 +81,10 @@ impl TextSel {
|
||||
pub fn ordered(&self) -> ((usize, usize), (usize, usize)) {
|
||||
if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) }
|
||||
}
|
||||
/// Is the caret drawn at the end of the line before (upstream), not the start of the next?
|
||||
pub fn at_line_end(&self) -> bool {
|
||||
self.line_end == Some(self.caret)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view).
|
||||
@@ -130,6 +139,24 @@ pub struct Interaction {
|
||||
pub selection: Selection,
|
||||
}
|
||||
|
||||
/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start`
|
||||
/// for `len` characters, until it is committed, replaced or cancelled. The presentation from
|
||||
/// before it began makes the whole composition one undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Composition {
|
||||
/// Where the composed text starts: (paragraph, char).
|
||||
pub start: (usize, usize),
|
||||
/// Its length in characters (it never spans paragraphs).
|
||||
pub len: usize,
|
||||
/// The part the input method is converting, relative to `start` (drawn with a thicker line).
|
||||
pub active: Option<(usize, usize)>,
|
||||
/// The formatting the composed text is typed with (as `text.insert` would choose).
|
||||
pub props: Option<deckcraft_model::text::RunProps>,
|
||||
/// The presentation and selection before the composition began.
|
||||
pub before: Arc<Presentation>,
|
||||
pub before_selection: Selection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
pub doc: Arc<Presentation>,
|
||||
@@ -141,6 +168,11 @@ pub struct DocState {
|
||||
pub interaction: Option<Interaction>,
|
||||
pub uid: u64,
|
||||
pub title: String,
|
||||
/// Fonts the file's text uses that aren't installed, found when it was opened (empty for new
|
||||
/// documents); the app mentions them once.
|
||||
pub missing_fonts: Vec<String>,
|
||||
/// An input method's composition in progress ([`Composition`]).
|
||||
pub composition: Option<Composition>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -158,6 +190,8 @@ impl DocState {
|
||||
interaction: None,
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
title,
|
||||
missing_fonts: Vec::new(),
|
||||
composition: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -300,6 +334,8 @@ pub struct Session {
|
||||
pub(crate) untitled: u32,
|
||||
/// Format Painter: copied shape/text formatting and whether it stays on (double-click).
|
||||
pub painter: Option<(cmd::format::Painted, bool)>,
|
||||
/// Animation Painter: copied shape animations and whether it stays on (double-click).
|
||||
pub anim_painter: Option<(Vec<deckcraft_model::anim::Animation>, bool)>,
|
||||
/// Set as Default Shape: the look new shapes get.
|
||||
pub default_look: Option<cmd::format::Painted>,
|
||||
/// Nesting of commands run by commands: only the outermost one is an undo step and a journal
|
||||
@@ -309,6 +345,12 @@ pub struct Session {
|
||||
pub recovery_dir: Option<std::path::PathBuf>,
|
||||
/// Media playback state, written by the UI host every frame (empty without a host).
|
||||
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
|
||||
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
|
||||
pub gif_paused: std::collections::HashSet<ShapeId>,
|
||||
/// Text keys follow the Mac (Option+arrows move by word, Cmd+arrows to the line or text
|
||||
/// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the
|
||||
/// platform DeckCraft was built for.
|
||||
pub mac_keys: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -331,9 +373,12 @@ impl Session {
|
||||
untitled: 0,
|
||||
default_look: None,
|
||||
painter: None,
|
||||
anim_painter: None,
|
||||
depth: 0,
|
||||
recovery_dir: None,
|
||||
media_status: Default::default(),
|
||||
gif_paused: Default::default(),
|
||||
mac_keys: cfg!(target_os = "macos"),
|
||||
}
|
||||
}
|
||||
/// A session with one new presentation open.
|
||||
@@ -392,6 +437,10 @@ impl Session {
|
||||
|
||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// Any other edit (or a click, a key, undo) settles an input method's composition first.
|
||||
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
|
||||
self.end_composition();
|
||||
}
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
||||
if !named {
|
||||
@@ -442,10 +491,24 @@ impl Session {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Keep an input method's composition as it stands (#101): its text stays and the whole
|
||||
/// composition becomes one undo step. Nothing happens without one.
|
||||
pub fn end_composition(&mut self) {
|
||||
let Some(st) = self.active_mut() else { return };
|
||||
let Some(c) = st.composition.take() else { return };
|
||||
if !Arc::ptr_eq(&c.before, &st.doc) {
|
||||
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection });
|
||||
}
|
||||
}
|
||||
|
||||
/// Change only the selection (no undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||
let st = self.doc_mut()?;
|
||||
let doc = st.doc.clone();
|
||||
// A caret placed anywhere starts downstream; only End puts it back at a line's end.
|
||||
if let Some(t) = st.selection.text.as_mut() {
|
||||
t.line_end = None;
|
||||
}
|
||||
f(&doc, &mut st.selection);
|
||||
clamp_selection(st);
|
||||
st.revision += 1;
|
||||
@@ -490,3 +553,5 @@ pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_keys;
|
||||
|
||||
@@ -366,3 +366,411 @@ fn freeform_tools_and_command() {
|
||||
g => panic!("{g:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_in_table_uses_grown_row_heights() {
|
||||
// Rows stored 4 pt tall are drawn tall enough for their text, so 6 pt down is still row 0.
|
||||
let mut s = session();
|
||||
let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]);
|
||||
let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone();
|
||||
for row in 0..3 {
|
||||
s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap();
|
||||
}
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grown_table_frame_covers_its_drawn_rows() {
|
||||
// Rows stored 4 pt tall are drawn taller: the box, its handles and hit tests follow the drawn rows.
|
||||
let mut s = session();
|
||||
let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]);
|
||||
let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone();
|
||||
for row in 0..3 {
|
||||
s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap();
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone();
|
||||
let deckcraft_model::ShapeKind::Table(t) = &sh.kind else { panic!("not a table") };
|
||||
let drawn: f64 = tools::table_rows(st, t).iter().sum();
|
||||
assert!(drawn > 12.0, "rows should grow: {drawn}");
|
||||
let x = cmd::xfrm_of(&st.doc, &st.selection, &sh);
|
||||
assert!((x.h - drawn).abs() < 1e-6, "frame {} vs drawn {drawn}", x.h);
|
||||
let below_stored = deckcraft_geom::Point::new(120.0, 100.0 + drawn - 2.0);
|
||||
assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap();
|
||||
s.execute("format.rtl", &json!({})).unwrap();
|
||||
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone();
|
||||
assert_eq!(para(&s).rtl, Some(true));
|
||||
// Left (the default) becomes right: the text stays at its start edge.
|
||||
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
|
||||
s.execute("format.align", &json!({"align": "justifyLow"})).unwrap();
|
||||
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
|
||||
// Toggling back keeps a justified alignment.
|
||||
s.execute("format.rtl", &json!({})).unwrap();
|
||||
assert_eq!(para(&s).rtl, Some(false));
|
||||
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
|
||||
s.execute("format.rtl", &json!({"on": true})).unwrap();
|
||||
s.execute("format.alignLeft", &json!({})).unwrap();
|
||||
s.execute("format.rtl", &json!({"on": false})).unwrap();
|
||||
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(para(&s).rtl, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_fixed_date_is_the_slides_own_text() {
|
||||
use deckcraft_model::text::RunKind;
|
||||
let mut s = session();
|
||||
let date = |s: &Session| {
|
||||
let slide = s.doc().unwrap().current_slide().unwrap();
|
||||
slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).and_then(|x| x.text.clone()).unwrap()
|
||||
};
|
||||
let kind = |s: &Session| date(s).paragraphs[0].runs[0].kind.clone();
|
||||
// Automatic: a date field, saved with today's date.
|
||||
s.execute("design.headerFooter", &json!({"date": true})).unwrap();
|
||||
assert_eq!(kind(&s), RunKind::Field { field: "datetime1".into() });
|
||||
assert_eq!(date(&s).text().matches('/').count(), 2, "M/D/YYYY");
|
||||
// Fixed: plain text in the slide's own placeholder (other slides' fields aren't affected).
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "Launch day"})).unwrap();
|
||||
assert_eq!((kind(&s), date(&s).text()), (RunKind::Text, "Launch day".to_string()));
|
||||
// Back to automatic.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": ""})).unwrap();
|
||||
assert!(matches!(kind(&s), RunKind::Field { .. }));
|
||||
// A new date field is saved with the date it shows.
|
||||
s.execute("text.edit", &json!({"id": s.doc().unwrap().current_slide().unwrap().shapes[0].id.0})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Updated "})).unwrap();
|
||||
s.execute("insert.dateTime", &json!({"format": "datetime10"})).unwrap();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap();
|
||||
let run = title.paragraphs[0].runs.iter().find(|r| matches!(r.kind, RunKind::Field { .. })).cloned().unwrap();
|
||||
assert!(run.text.contains(':'), "H:mm {run:?}");
|
||||
}
|
||||
|
||||
/// (text, linked?) of each run in the first paragraph of the current slide's title.
|
||||
fn title_runs(s: &Session) -> Vec<(String, bool)> {
|
||||
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
|
||||
let p = &sh.text.as_ref().unwrap().paragraphs[0];
|
||||
p.runs.iter().map(|r| (r.text.clone(), r.props.link.is_some())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_with_a_bare_caret_inserts_the_address() {
|
||||
// #31: with the caret at the end of the text, Insert Hyperlink used to format an empty range
|
||||
// and change nothing.
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "See "})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": " now"})).unwrap();
|
||||
assert_eq!(
|
||||
title_runs(&s),
|
||||
vec![("See ".into(), false), ("https://example.org".into(), true), (" now".into(), false)],
|
||||
"the address is inserted as a link, and typing after it doesn't extend it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_display_text_and_targets() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"url": "mailto:a@b.org"})).unwrap();
|
||||
assert_eq!(title_runs(&s), vec![("a@b.org".into(), true)], "email links show the address without mailto:");
|
||||
s.execute("text.insert", &json!({"text": " "})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"slide": 0, "text": "start"})).unwrap();
|
||||
assert_eq!(title_runs(&s), vec![("a@b.org".into(), true), (" ".into(), false), ("start".into(), true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_on_a_word_or_shape_links_it() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "left"})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
|
||||
assert_eq!(title_runs(&s), vec![("Hello ".into(), false), ("world".into(), true)], "a caret inside a word links the word");
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
|
||||
assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action");
|
||||
}
|
||||
|
||||
/// A .pptx (base64) whose first slide's title is set in `font`.
|
||||
fn deck_in_font(font: &str) -> String {
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = deckcraft_model::text::TextBody::from_text("Hello");
|
||||
body.paragraphs[0].runs[0].props.font = Some(font.into());
|
||||
slide.shapes[0].text = Some(body);
|
||||
crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font("Qqq Nonexistent Sans")})).unwrap();
|
||||
assert_eq!(r["missingFonts"], json!(["Qqq Nonexistent Sans"]));
|
||||
assert_eq!(s.doc().unwrap().missing_fonts, vec!["Qqq Nonexistent Sans".to_string()]);
|
||||
// A font that is there (bundled or installed) isn't listed; new documents list nothing.
|
||||
let have = deckcraft_fonts::FontDb::global().face(deckcraft_fonts::FALLBACK_FAMILY, "Regular").family.clone();
|
||||
let r = s.execute("file.openBytes", &json!({"name": "b.pptx", "data": deck_in_font(&have)})).unwrap();
|
||||
assert_eq!(r["missingFonts"], json!([]));
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().missing_fonts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() {
|
||||
// Flips don't mirror text, so a click lands on the character drawn under it.
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 400, 100], "text": "Hello flipped world"})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
let pos = |s: &Session, x: f64, y: f64| {
|
||||
let st = s.doc().unwrap();
|
||||
tools::text_pos(st, st.shape(id).unwrap(), deckcraft_geom::Point::new(x, y))
|
||||
};
|
||||
let xs = [240.0, 300.0, 360.0];
|
||||
let plain = xs.map(|x| pos(&s, x, 150.0));
|
||||
assert!(plain[0].1 < plain[1].1 && plain[1].1 < plain[2].1, "{plain:?}");
|
||||
s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert_eq!(xs.map(|x| pos(&s, x, 150.0)), plain);
|
||||
// flipV turns the text 180° about the box centre (300, 150).
|
||||
s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap();
|
||||
s.execute("shape.flip", &json!({"axis": "vertical"})).unwrap();
|
||||
assert_eq!(xs.map(|x| pos(&s, 600.0 - x, 150.0)), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_hyperlinks_look_like_links() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Read the docs"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
||||
s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap();
|
||||
// Underlined (and hlink-coloured: same resolution as files) without touching the run's own props.
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], true);
|
||||
let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap();
|
||||
let run = body.paragraphs[0].runs.iter().find(|r| r.props.link.is_some()).unwrap();
|
||||
assert!(run.props.fill.is_none() && run.props.underline.is_none(), "{run:?}");
|
||||
// Underline off is the run's own u="none", as in PowerPoint.
|
||||
s.execute("format.underline", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false);
|
||||
}
|
||||
|
||||
/// #270: shapes off the slide (in the editor's pasteboard) are hit, selected, moved and marqueed
|
||||
/// like any other; one can be dragged back onto the slide or out of it.
|
||||
#[test]
|
||||
fn shapes_off_the_slide_can_be_selected_and_moved() {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
let off = s.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let half = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [-60.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
// Inserting off the slide keeps the box where it was asked for.
|
||||
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0);
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||
// Hover and click wholly off the slide.
|
||||
assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off));
|
||||
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 1060.0, 140.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![off]);
|
||||
// Drag it further off, then back onto the slide.
|
||||
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 1110.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 1110.0, 160.0)).unwrap();
|
||||
let v = s.execute("shape.inspect", &json!({"id": off})).unwrap();
|
||||
assert_eq!((v["box"]["x"].as_f64(), v["box"]["y"].as_f64()), (Some(1050.0), Some(120.0)));
|
||||
s.pointer(ev(PointerKind::Down, 1110.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 500.0, 160.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 500.0, 160.0)).unwrap();
|
||||
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 440.0);
|
||||
// The off-slide half of a straddling shape is hit too.
|
||||
s.pointer(ev(PointerKind::Down, -30.0, 340.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, -30.0, 340.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||
// A marquee started on the pasteboard selects what it encloses there.
|
||||
s.pointer(ev(PointerKind::Down, -100.0, 250.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 80.0, 420.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||
}
|
||||
|
||||
fn title_text(s: &Session) -> String {
|
||||
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ →
|
||||
/// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the
|
||||
/// committed text is one undo step, and nothing on the way is an undo step of its own.
|
||||
#[test]
|
||||
fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "A "})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
|
||||
s.compose_text(pre, None).unwrap();
|
||||
assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place");
|
||||
let c = s.doc().unwrap().composition.clone().unwrap();
|
||||
assert_eq!((c.start, c.len), ((0, 2), pre.chars().count()));
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing");
|
||||
}
|
||||
s.commit_text("한글").unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글");
|
||||
assert!(s.doc().unwrap().composition.is_none());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A ");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글");
|
||||
|
||||
// Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit,
|
||||
// then a new preedit.
|
||||
s.compose_text("ㄴ", None).unwrap();
|
||||
s.compose_text("", None).unwrap();
|
||||
s.commit_text("ㄴ").unwrap();
|
||||
s.compose_text("ㅁ", None).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴㅁ");
|
||||
s.commit_text("마").unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴ마");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴ");
|
||||
}
|
||||
|
||||
/// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo):
|
||||
/// the text and the selection it replaced are as they were, with no undo step.
|
||||
#[test]
|
||||
fn ime_cancel_restores_the_text_and_selection() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
s.compose_text("せかい", Some((0, 3))).unwrap();
|
||||
assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection");
|
||||
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3)));
|
||||
s.compose_text("世界", Some((0, 9))).unwrap();
|
||||
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit");
|
||||
s.compose_text("", None).unwrap();
|
||||
assert_eq!(title_text(&s), "Hello world");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||
assert!(s.doc().unwrap().revision > rev, "redrawn");
|
||||
// A commit of nothing without a composition is nothing.
|
||||
s.commit_text("").unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||
// A commit without a preedit (some input methods send only the result) is one step.
|
||||
s.commit_text("世界").unwrap();
|
||||
assert_eq!(title_text(&s), "Hello 世界");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||
}
|
||||
|
||||
/// #101: anything else settles the composition first: its text stays and is its own undo step.
|
||||
#[test]
|
||||
fn ime_composition_is_kept_by_a_key_a_click_or_another_command() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
s.compose_text("안", None).unwrap();
|
||||
s.key("Right", Mods::default()).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_none());
|
||||
assert_eq!(title_text(&s), "안");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||
|
||||
s.compose_text("녕", None).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing");
|
||||
s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_none(), "a click settles it");
|
||||
assert_eq!(title_text(&s), "안녕");
|
||||
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.compose_text("하", None).unwrap();
|
||||
s.execute("text.get", &json!({"id": title})).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_some(), "a query doesn't");
|
||||
s.execute("text.insert", &json!({"text": "!"})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕하!");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕하");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕");
|
||||
|
||||
// Text changed under the composition (here behind its back): a new one starts, no panic.
|
||||
s.compose_text("ㄱ", None).unwrap();
|
||||
s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99;
|
||||
s.compose_text("가", None).unwrap();
|
||||
assert!(title_text(&s).ends_with('가'));
|
||||
// Control characters (an Enter sent as text) never split the paragraph.
|
||||
s.commit_text("가\n\r\u{b}").unwrap();
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1);
|
||||
}
|
||||
|
||||
/// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing
|
||||
/// does; with nothing selected there is nowhere to compose.
|
||||
#[test]
|
||||
fn ime_on_a_selected_shape_replaces_its_text() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes = vec![ShapeId(id as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing");
|
||||
s.compose_text("新", None).unwrap();
|
||||
s.commit_text("新しい").unwrap();
|
||||
let v = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(v["text"], "新しい");
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes.clear();
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.compose_text("x", None).unwrap()["handled"], false);
|
||||
assert_eq!(s.commit_text("x").unwrap()["handled"], false);
|
||||
}
|
||||
|
||||
/// #101: the composition is underlined in place, under the composed characters, with a thicker
|
||||
/// line under the part being converted.
|
||||
#[test]
|
||||
fn ime_composition_underlines_follow_the_text() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
s.compose_text("にほんご", Some((2, 4))).unwrap();
|
||||
let u = cmd::text::composition_underlines(s.doc().unwrap());
|
||||
let thin: Vec<_> = u.iter().filter(|x| !x.2).collect();
|
||||
let thick: Vec<_> = u.iter().filter(|x| x.2).collect();
|
||||
assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}");
|
||||
let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone();
|
||||
let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh);
|
||||
for (a, b, _) in &u {
|
||||
assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}");
|
||||
assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box");
|
||||
}
|
||||
assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end");
|
||||
s.commit_text("日本語").unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Text-editing keys driven through `Session::key`, as the keyboard, control channel and MCP
|
||||
//! send them. Every expectation is PowerPoint for Windows' observed behaviour unless it says Mac.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::Mods;
|
||||
|
||||
const NONE: Mods = Mods { shift: false, alt: false, cmd: false };
|
||||
const CTRL: Mods = Mods { shift: false, alt: false, cmd: true };
|
||||
const CTRL_SHIFT: Mods = Mods { shift: true, alt: false, cmd: true };
|
||||
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false };
|
||||
const ALT: Mods = Mods { shift: false, alt: true, cmd: false };
|
||||
|
||||
type Pos = (usize, usize);
|
||||
|
||||
/// A session editing a new text box holding `text` (Windows keys), caret at `at`.
|
||||
fn editing(text: &str, at: Pos) -> (Session, u32) {
|
||||
editing_in(text, at, [60.0, 60.0, 600.0, 300.0])
|
||||
}
|
||||
|
||||
fn editing_in(text: &str, at: Pos, rect: [f64; 4]) -> (Session, u32) {
|
||||
let mut s = Session::with_new();
|
||||
s.mac_keys = false;
|
||||
s.prefs.autocorrect = false;
|
||||
s.prefs.smart_quotes = false;
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": "x"})).unwrap();
|
||||
let id = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("text.set", &json!({"id": id, "text": text})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "at": [at.0, at.1]})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn sel(s: &Session) -> (Pos, Pos) {
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
(t.anchor, t.caret)
|
||||
}
|
||||
|
||||
fn caret(s: &Session) -> Pos {
|
||||
sel(s).1
|
||||
}
|
||||
|
||||
fn press(s: &mut Session, key: &str, mods: Mods) -> Value {
|
||||
let r = s.key(key, mods).unwrap();
|
||||
assert_eq!(r["handled"], true, "{key} {mods:?} not handled");
|
||||
r
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, id: u32) -> String {
|
||||
s.execute("text.get", &json!({"id": id})).unwrap()["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Press `key` until the caret stops moving; the carets visited (in the first paragraph).
|
||||
fn walk(s: &mut Session, key: &str, mods: Mods) -> Vec<usize> {
|
||||
let mut out = vec![caret(s).1];
|
||||
for _ in 0..200 {
|
||||
let before = caret(s);
|
||||
press(s, key, mods);
|
||||
if caret(s) == before {
|
||||
break;
|
||||
}
|
||||
out.push(caret(s).1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_stop_where_powerpoint_does() {
|
||||
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
|
||||
let stops = vec![0, 5, 7, 12, 15, 18, 21, 25, 33, 38, 43, 44, 45, 46, 48, 49, 50, 52, 53, 54, 55, 56, 60, 61, 64, 66, 67, 68, 70, 73, 74];
|
||||
let (mut s, _) = editing(t, (0, 0));
|
||||
assert_eq!(walk(&mut s, "ArrowRight", CTRL), stops);
|
||||
let mut back = stops.clone();
|
||||
back.reverse();
|
||||
assert_eq!(walk(&mut s, "ArrowLeft", CTRL), back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_over_cjk_rtl_emoji_and_marks() {
|
||||
let (mut s, _) = editing("日本語のテキスト abc 한국어", (0, 0));
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 1, 2, 3, 4, 5, 6, 7, 9, 13, 14, 15, 16]);
|
||||
// Arabic and Hebrew words inside a left-to-right paragraph: in logical order.
|
||||
let (mut s, _) = editing("Hello مرحبا بالعالم world שלום end", (0, 0));
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 6, 12, 20, 26, 31, 34]);
|
||||
// An emoji ZWJ sequence and a combining mark are never split.
|
||||
let (mut s, _) = editing("e\u{301}te 👨\u{200d}👩\u{200d}👧 x", (0, 0));
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 5, 11, 12]);
|
||||
assert_eq!(walk(&mut s, "Left", CTRL), vec![12, 11, 5, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_cross_paragraphs_line_breaks_and_tabs() {
|
||||
let (mut s, id) = editing("One two\nThree four\n\nFive", (0, 0));
|
||||
let mut seen = vec![caret(&s)];
|
||||
for _ in 0..9 {
|
||||
press(&mut s, "Right", CTRL);
|
||||
seen.push(caret(&s));
|
||||
}
|
||||
assert_eq!(seen, vec![(0, 0), (0, 4), (0, 7), (1, 0), (1, 6), (1, 10), (2, 0), (3, 0), (3, 4), (3, 4)]);
|
||||
// A line break and a tab are stops of their own.
|
||||
s.execute("text.set", &json!({"id": id, "text": "x"})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Five\u{b}six seven\tTab x"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 4, 5, 9, 14, 15, 21, 22]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_arrows_extend_by_words() {
|
||||
let (mut s, _) = editing("Hello world foo, bar\nnext para", (0, 2));
|
||||
let mut carets = vec![];
|
||||
for _ in 0..6 {
|
||||
press(&mut s, "Right", CTRL_SHIFT);
|
||||
carets.push(sel(&s));
|
||||
}
|
||||
let a = (0, 2);
|
||||
assert_eq!(carets.iter().map(|x| x.1).collect::<Vec<_>>(), vec![(0, 6), (0, 13), (0, 16), (0, 18), (0, 21), (1, 0)]);
|
||||
assert!(carets.iter().all(|x| x.0 == a));
|
||||
let mut back = vec![];
|
||||
for _ in 0..7 {
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
back.push(caret(&s));
|
||||
}
|
||||
assert_eq!(back, vec![(0, 21), (0, 18), (0, 16), (0, 13), (0, 6), (0, 0), (0, 0)]);
|
||||
assert_eq!(sel(&s).0, a);
|
||||
// From a caret inside a word going left: the word's start, then on.
|
||||
let (mut s, _) = editing("Hello world foo, bar\nnext para", (1, 2));
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((1, 2), (0, 21)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_leave_a_selection_from_its_ends() {
|
||||
for (anchor, caret_at) in [((0, 2), (0, 7)), ((0, 7), (0, 2))] {
|
||||
let (mut s, _) = editing("Hello world foo, bar", anchor);
|
||||
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
|
||||
press(&mut s, "Right", CTRL);
|
||||
assert_eq!(sel(&s), ((0, 13), (0, 13)));
|
||||
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
|
||||
press(&mut s, "Left", CTRL);
|
||||
assert_eq!(sel(&s), ((0, 0), (0, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_backspace_and_delete() {
|
||||
let (mut s, id) = editing("Hello world foo, bar\nnext para", (0, 13));
|
||||
press(&mut s, "Backspace", CTRL);
|
||||
assert_eq!(text(&mut s, id), "Hello foo, bar\nnext para");
|
||||
assert_eq!(caret(&s), (0, 6));
|
||||
press(&mut s, "Delete", CTRL);
|
||||
assert_eq!(text(&mut s, id), "Hello , bar\nnext para".replace(" ,", ","));
|
||||
assert_eq!(caret(&s), (0, 5));
|
||||
// At a paragraph's ends they join paragraphs.
|
||||
s.execute("text.move", &json!({"to": "paraEnd"})).unwrap();
|
||||
press(&mut s, "Delete", CTRL);
|
||||
assert_eq!(text(&mut s, id), "Hello, barnext para");
|
||||
// A selection is not deleted: the word goes from its start (PowerPoint for Windows).
|
||||
let (mut s, id) = editing("Hello world", (0, 2));
|
||||
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 7]})).unwrap();
|
||||
press(&mut s, "Backspace", CTRL);
|
||||
assert_eq!((text(&mut s, id), sel(&s)), ("llo world".into(), ((0, 0), (0, 0))));
|
||||
let (mut s, id) = editing("Hello world", (0, 7));
|
||||
s.execute("text.select", &json!({"anchor": [0, 7], "caret": [0, 2]})).unwrap();
|
||||
press(&mut s, "Delete", CTRL);
|
||||
assert_eq!((text(&mut s, id), sel(&s)), ("He world".into(), ((0, 2), (0, 2))));
|
||||
// It is one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&mut s, id), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_up_down_move_by_paragraph() {
|
||||
let t = "First paragraph that is long enough to wrap in this box.\nSecond\n\nFourth para";
|
||||
let (mut s, _) = editing_in(t, (0, 5), [60.0, 60.0, 200.0, 300.0]);
|
||||
let mut seen = vec![];
|
||||
for _ in 0..5 {
|
||||
press(&mut s, "Down", CTRL);
|
||||
seen.push(caret(&s));
|
||||
}
|
||||
assert_eq!(seen, vec![(1, 0), (2, 0), (3, 0), (3, 11), (3, 11)]);
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
press(&mut s, "Left", NONE);
|
||||
let mut seen = vec![];
|
||||
for _ in 0..5 {
|
||||
press(&mut s, "Up", CTRL);
|
||||
seen.push(caret(&s));
|
||||
}
|
||||
assert_eq!(seen, vec![(3, 0), (2, 0), (1, 0), (0, 0), (0, 0)]);
|
||||
// With Shift they extend; back past the anchor the selection flips.
|
||||
s.execute("text.edit", &json!({"at": [0, 5]})).unwrap();
|
||||
press(&mut s, "Down", CTRL_SHIFT);
|
||||
press(&mut s, "Down", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 5), (2, 0)));
|
||||
press(&mut s, "Up", CTRL_SHIFT);
|
||||
press(&mut s, "Up", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 5), (0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_end_and_ctrl_home_end() {
|
||||
let t = "First paragraph that is long enough to wrap across several lines in this narrow box.\nSecond";
|
||||
let (mut s, _) = editing_in(t, (0, 0), [60.0, 60.0, 160.0, 300.0]);
|
||||
let st = s.doc().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
let layout = crate::cmd::text::layout_for(st, &ts).unwrap();
|
||||
let lines: Vec<(usize, usize)> = layout.lines.iter().filter(|l| l.para == 0).map(|l| (l.start, l.end)).collect();
|
||||
assert!(lines.len() >= 3, "{lines:?}");
|
||||
let (l1, l2) = (lines[1], lines[2]);
|
||||
// End goes past a wrapped line's trailing space, to where the next line starts, as in
|
||||
// PowerPoint, and the caret stays drawn on the line it ended.
|
||||
let next = lines[2].0;
|
||||
s.execute("text.edit", &json!({"at": [0, l1.0 + 2]})).unwrap();
|
||||
press(&mut s, "Home", NONE);
|
||||
assert_eq!(caret(&s), (0, l1.0));
|
||||
press(&mut s, "End", NONE);
|
||||
assert_eq!(caret(&s), (0, next));
|
||||
assert!(s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
|
||||
let drawn = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
let l = crate::cmd::text::layout_for(st, &ts).unwrap();
|
||||
let aff = if ts.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||
l.caret_drawn(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }, aff).unwrap()
|
||||
};
|
||||
let line1_top = layout.lines.iter().filter(|l| l.para == 0).nth(1).unwrap().top;
|
||||
assert_eq!(drawn(&s).1, line1_top);
|
||||
// End again stays on the line; Home goes back to its start, not the next line's.
|
||||
press(&mut s, "End", NONE);
|
||||
assert_eq!(caret(&s), (0, next));
|
||||
press(&mut s, "Home", SHIFT);
|
||||
assert_eq!(sel(&s), ((0, next), (0, l1.0)));
|
||||
press(&mut s, "End", SHIFT);
|
||||
assert_eq!(sel(&s), ((0, next), (0, next)));
|
||||
assert_eq!(drawn(&s).1, line1_top);
|
||||
// Down from a line's end lands on the next line, not two lines down.
|
||||
press(&mut s, "Down", NONE);
|
||||
let c = caret(&s);
|
||||
assert!(c.1 > l2.0 && c.1 <= l2.1, "{c:?} not on line {l2:?}");
|
||||
// The caret is drawn at the end of the line it belongs to.
|
||||
s.execute("text.move", &json!({"to": "lineEnd"})).unwrap();
|
||||
let ts = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert!(ts.at_line_end());
|
||||
// Ctrl+Home / Ctrl+End, with Shift extending.
|
||||
press(&mut s, "End", CTRL);
|
||||
assert_eq!(caret(&s), (1, 6));
|
||||
press(&mut s, "Home", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((1, 6), (0, 0)));
|
||||
press(&mut s, "Home", CTRL);
|
||||
press(&mut s, "End", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 0), (1, 6)));
|
||||
// A caret placed anywhere else is drawn at the start of its line again.
|
||||
s.execute("text.select", &json!({"anchor": [0, next], "caret": [0, next]})).unwrap();
|
||||
assert!(!s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
|
||||
assert!(drawn(&s).1 > line1_top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs_swap_left_and_right() {
|
||||
let (mut s, _) = editing("مرحبا بالعالم hello world", (0, 0));
|
||||
s.execute("format.rtl", &json!({"on": true})).unwrap();
|
||||
// Ctrl+Left goes forward in a right-to-left paragraph, Ctrl+Right back.
|
||||
assert_eq!(walk(&mut s, "Left", CTRL), vec![0, 6, 14, 20, 25]);
|
||||
assert_eq!(walk(&mut s, "Right", CTRL), vec![25, 20, 14, 6, 0]);
|
||||
press(&mut s, "Left", NONE);
|
||||
assert_eq!(caret(&s), (0, 1));
|
||||
press(&mut s, "Left", CTRL_SHIFT);
|
||||
assert_eq!(sel(&s), ((0, 1), (0, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_keys() {
|
||||
let (mut s, id) = editing("Hello world foo\nnext", (0, 8));
|
||||
s.mac_keys = true;
|
||||
press(&mut s, "Right", ALT);
|
||||
assert_eq!(caret(&s), (0, 12));
|
||||
press(&mut s, "Left", ALT);
|
||||
assert_eq!(caret(&s), (0, 6));
|
||||
press(&mut s, "Right", CTRL);
|
||||
assert_eq!(caret(&s), (0, 15));
|
||||
press(&mut s, "Left", CTRL);
|
||||
assert_eq!(caret(&s), (0, 0));
|
||||
press(&mut s, "Down", CTRL);
|
||||
assert_eq!(caret(&s), (1, 4));
|
||||
press(&mut s, "Up", CTRL);
|
||||
assert_eq!(caret(&s), (0, 0));
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 11]})).unwrap();
|
||||
press(&mut s, "Backspace", ALT);
|
||||
assert_eq!(text(&mut s, id), "Hello foo\nnext");
|
||||
// Ctrl+arrows aren't word moves on the Mac, nor Alt+arrows on Windows.
|
||||
s.mac_keys = false;
|
||||
let before = caret(&s);
|
||||
let r = s.key("Right", ALT).unwrap();
|
||||
assert_eq!((r["handled"].as_bool(), caret(&s)), (Some(false), before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_keys() {
|
||||
let (mut s, _) = editing("hello wOrld. foo bar", (0, 0));
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
|
||||
let size = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["size"].as_f64();
|
||||
let base = size(&mut s).unwrap_or(18.0);
|
||||
// egui names the keys `Period`, `Comma`, `CloseBracket`, `OpenBracket`.
|
||||
press(&mut s, "Period", CTRL_SHIFT);
|
||||
let up = size(&mut s).unwrap();
|
||||
assert!(up > base);
|
||||
press(&mut s, "Comma", CTRL_SHIFT);
|
||||
assert_eq!(size(&mut s), Some(base));
|
||||
press(&mut s, "CloseBracket", CTRL);
|
||||
assert_eq!(size(&mut s), Some(up));
|
||||
press(&mut s, "OpenBracket", CTRL);
|
||||
assert_eq!(size(&mut s), Some(base));
|
||||
press(&mut s, ">", CTRL_SHIFT);
|
||||
assert_eq!(size(&mut s), Some(up));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_underline_and_clear_on_a_caret_in_a_word() {
|
||||
let (mut s, id) = editing("hello wOrld. foo bar", (0, 8));
|
||||
let r = press(&mut s, "b", CTRL);
|
||||
assert_eq!(r["command"], "format.bold");
|
||||
let body = |s: &Session| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||
let runs: Vec<(String, Option<bool>)> = body(&s).paragraphs[0].runs.iter().map(|r| (r.text.clone(), r.props.bold)).collect();
|
||||
assert_eq!(runs.iter().find(|r| r.1 == Some(true)).map(|r| r.0.as_str()), Some("wOrld"));
|
||||
// At a word boundary nothing visible changes.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 5]})).unwrap();
|
||||
press(&mut s, "i", CTRL);
|
||||
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.italic != Some(true)));
|
||||
// Ctrl+Space clears the word's formatting.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
|
||||
press(&mut s, "Space", CTRL);
|
||||
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.bold != Some(true)));
|
||||
press(&mut s, "u", CTRL);
|
||||
let under: String = body(&s).paragraphs[0].runs.iter().filter(|r| r.props.underline.is_some()).map(|r| r.text.clone()).collect();
|
||||
assert_eq!(under, "wOrld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_keys() {
|
||||
let (mut s, _) = editing("one\ntwo", (0, 1));
|
||||
for (k, want) in [("e", "ctr"), ("r", "r"), ("j", "just"), ("l", "l")] {
|
||||
press(&mut s, k, CTRL);
|
||||
let st = s.execute("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st["align"], want, "Ctrl+{k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_f3_cycles_case() {
|
||||
let (mut s, id) = editing("hello world", (0, 0));
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
let mut seen = vec![];
|
||||
for _ in 0..3 {
|
||||
press(&mut s, "F3", SHIFT);
|
||||
seen.push(text(&mut s, id));
|
||||
}
|
||||
assert_eq!(seen, vec!["Hello World", "HELLO WORLD", "hello world"]);
|
||||
s.execute("text.set", &json!({"id": id, "text": "hello wOrld. foo"})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
|
||||
press(&mut s, "F3", SHIFT);
|
||||
assert_eq!(text(&mut s, id), "hello WORLD. foo");
|
||||
assert_eq!(sel(&s), ((0, 6), (0, 6)));
|
||||
press(&mut s, "F3", SHIFT);
|
||||
assert_eq!(text(&mut s, id), "hello world. foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_and_word_and_paragraph_selection() {
|
||||
let (mut s, id) = editing("Hello world, foo bar\nnext", (0, 7));
|
||||
s.execute("text.selectWord", &json!({})).unwrap();
|
||||
assert_eq!(sel(&s), ((0, 6), (0, 11)));
|
||||
s.execute("text.selectWord", &json!({"at": [0, 12]})).unwrap();
|
||||
assert_eq!(sel(&s), ((0, 11), (0, 12)));
|
||||
// Triple-click: the paragraph and its mark.
|
||||
s.execute("text.selectParagraph", &json!({"at": [0, 3]})).unwrap();
|
||||
assert_eq!(sel(&s), ((0, 0), (1, 0)));
|
||||
s.execute("text.selectParagraph", &json!({"at": [1, 1]})).unwrap();
|
||||
assert_eq!(sel(&s), ((1, 0), (1, 4)));
|
||||
s.execute("text.edit", &json!({"id": id, "at": [1, 2]})).unwrap();
|
||||
press(&mut s, "Enter", SHIFT);
|
||||
let b = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||
assert_eq!(b.paragraphs.len(), 2);
|
||||
assert_eq!(b.paragraphs[1].text(), "ne\u{b}xt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_never_panic_on_any_text() {
|
||||
let texts = ["", " ", "\u{b}", "\t\t", "👨\u{200d}👩\u{200d}👧", "e\u{301}", "日本", "مرحبا", "a\n\nb", " x \n "];
|
||||
let keys = ["Left", "Right", "Up", "Down", "Home", "End", "Backspace", "Delete"];
|
||||
for t in texts {
|
||||
for k in keys {
|
||||
for m in [CTRL, CTRL_SHIFT, NONE, SHIFT, ALT] {
|
||||
for at in [(0, 0), (0, 1), (5, 5)] {
|
||||
let (mut s, _) = editing(t, at);
|
||||
for _ in 0..3 {
|
||||
let _ = s.key(k, m).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-15
@@ -219,6 +219,10 @@ impl Session {
|
||||
if st.selection.text.is_none() || st.selection.shapes.len() == 1 {
|
||||
for id in st.selection.shapes.iter().rev() {
|
||||
let Some(sh) = st.shape(*id) else { continue };
|
||||
// A locked object shows no live handles: it can't be sized, rotated or reshaped.
|
||||
if sh.locked {
|
||||
continue;
|
||||
}
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
if sh.is_line() {
|
||||
let a = x.affine();
|
||||
@@ -247,8 +251,9 @@ impl Session {
|
||||
}
|
||||
}
|
||||
// Shapes, topmost first.
|
||||
// Locked objects are still picked (to format them or type in them); they just don't move.
|
||||
for sh in st.shapes().iter().rev() {
|
||||
if sh.hidden || sh.locked {
|
||||
if sh.hidden {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = hit_shape(st, sh, p, tol) {
|
||||
@@ -265,6 +270,10 @@ impl Session {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
|
||||
if !matches!(ev.kind, PointerKind::Move) {
|
||||
self.end_composition();
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
||||
@@ -419,7 +428,9 @@ impl Session {
|
||||
} else {
|
||||
vec![id]
|
||||
};
|
||||
let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
// Locked objects stay put when the selection is dragged.
|
||||
let origs: Vec<(ShapeId, Xfrm)> =
|
||||
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
// A click (no drag) on text puts the caret there; unselected text boxes and
|
||||
// placeholders take the caret straight away like PowerPoint.
|
||||
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
|
||||
@@ -429,11 +440,16 @@ impl Session {
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text });
|
||||
let duplicate = mods.alt && origs.len() == ids.len();
|
||||
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text });
|
||||
// Format Painter applies on click.
|
||||
if self.painter.is_some() && !toggle {
|
||||
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
|
||||
}
|
||||
// Animation Painter applies on click.
|
||||
if self.anim_painter.is_some() && !toggle {
|
||||
let _ = self.execute("animation.painterApply", &json!({"ids": [id]}));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let add = mods.shift || mods.cmd;
|
||||
@@ -456,7 +472,7 @@ impl Session {
|
||||
match drag {
|
||||
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
||||
let mut d = p - start;
|
||||
if !moved && d.hypot() < tol.max(1.0) {
|
||||
if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if !moved {
|
||||
@@ -789,7 +805,7 @@ impl Session {
|
||||
let x = xfrm_of(&st.doc, &st.selection, &sh);
|
||||
let local = x.affine().inverse() * p;
|
||||
if let ShapeKind::Table(t) = &sh.kind {
|
||||
let cell = deckcraft_render_cell(t, local);
|
||||
let cell = deckcraft_render_cell(t, &table_rows(st, t), local);
|
||||
return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]}));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
@@ -855,16 +871,28 @@ impl Session {
|
||||
}
|
||||
|
||||
fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> {
|
||||
// Rows scale from their drawn heights: a stored height is only a minimum (often 0).
|
||||
let drawn = self.active().and_then(|st| match &st.shape(id)?.kind {
|
||||
ShapeKind::Table(t) => Some(table_rows(st, t)),
|
||||
_ => None,
|
||||
});
|
||||
self.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?;
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(list, id) {
|
||||
sh.xfrm = Some(x);
|
||||
if let ShapeKind::Table(t) = &mut sh.kind {
|
||||
// Tables resize their columns and rows proportionally.
|
||||
let (tw, th) = (t.width().max(1e-6), t.height().max(1e-6));
|
||||
let (kx, ky) = (x.w / tw, x.h / th);
|
||||
let heights: Vec<f64> = match &drawn {
|
||||
Some(d) if d.len() == t.rows.len() => d.clone(),
|
||||
_ => t.rows.iter().map(|r| r.height).collect(),
|
||||
};
|
||||
let (tw, th) = (t.width().max(1e-6), heights.iter().map(|h| h.max(0.0)).sum::<f64>());
|
||||
let kx = x.w / tw;
|
||||
t.cols.iter_mut().for_each(|c| *c *= kx);
|
||||
t.rows.iter_mut().for_each(|r| r.height *= ky);
|
||||
if th > 1e-6 {
|
||||
let ky = x.h / th;
|
||||
t.rows.iter_mut().zip(&heights).for_each(|(r, h)| r.height = h.max(0.0) * ky);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1246,10 +1274,12 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
|
||||
/// Text position under slide point `p` in shape `sh`.
|
||||
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let local = x.affine().inverse() * p;
|
||||
let t = TextSel { shape: sh.id, ..Default::default() };
|
||||
match cmd::text::layout_for(st, &t) {
|
||||
Some(l) => {
|
||||
// The text's own transform: flips don't mirror text, vertical text is turned.
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p;
|
||||
let h = l.hit(local);
|
||||
(h.para, h.ch)
|
||||
}
|
||||
@@ -1257,16 +1287,21 @@ pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which table cell contains a table-local point.
|
||||
fn deckcraft_render_cell(t: &deckcraft_model::Table, p: Point) -> (usize, usize) {
|
||||
/// Row heights of table `t` as drawn: rows grow to fit their text.
|
||||
pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64> {
|
||||
cmd::table_drawn_heights(&st.doc, &st.selection, t)
|
||||
}
|
||||
|
||||
/// Which table cell contains a table-local point (`heights` from [`table_rows`]).
|
||||
fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) {
|
||||
let mut y = 0.0;
|
||||
let mut row = t.rows.len().saturating_sub(1);
|
||||
for (i, r) in t.rows.iter().enumerate() {
|
||||
if p.y < y + r.height {
|
||||
let mut row = heights.len().saturating_sub(1);
|
||||
for (i, h) in heights.iter().enumerate() {
|
||||
if p.y < y + h {
|
||||
row = i;
|
||||
break;
|
||||
}
|
||||
y += r.height;
|
||||
y += h;
|
||||
}
|
||||
let mut x = 0.0;
|
||||
let mut col = t.cols.len().saturating_sub(1);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Font fallback per grapheme cluster, and ligature caret positions.
|
||||
//!
|
||||
//! The cascade for a cluster (a base character with its marks, never split across faces) is:
|
||||
//! 1. the faces the document asks for, in order (the run's slot typeface, then the theme's script
|
||||
//! font, `a:font[@script="Arab"]`);
|
||||
//! 2. for scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the craft-fonts face
|
||||
//! bundled for the script (Noto Naskh Arabic for Arabic), so every machine draws the same; then
|
||||
//! openly licensed or OS-native families found by name among the installed fonts (no platform
|
||||
//! API is called); then any installed face covering the cluster;
|
||||
//! 3. any loaded face (bundled first: Japanese keeps its craft-fonts faces), then the first
|
||||
//! requested face (drawn as .notdef).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use skrifa::raw::TableProvider;
|
||||
|
||||
use crate::script::{ScriptTag, script_of};
|
||||
use crate::{CRAFT_FONTS, FontDb, FontFace};
|
||||
|
||||
/// Characters that never decide a face: they're drawn by whatever face the cluster gets.
|
||||
fn ignorable(c: char) -> bool {
|
||||
c.is_whitespace()
|
||||
|| c.is_control()
|
||||
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
|
||||
}
|
||||
|
||||
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
|
||||
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
|
||||
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
|
||||
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
return false;
|
||||
}
|
||||
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
|
||||
let default_emoji =
|
||||
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
|
||||
cluster.iter().any(|&c| default_emoji(c))
|
||||
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|
||||
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|
||||
|| cluster.contains(&'\u{20E3}')
|
||||
}
|
||||
|
||||
/// Does `face` draw every character of the cluster?
|
||||
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
|
||||
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
|
||||
}
|
||||
|
||||
/// Installed families tried by name for a script after the bundled face, before scanning every
|
||||
/// installed font. Microsoft font names are not listed (AGENTS.md §1.1); an installed one is
|
||||
/// still used by the scan when nothing earlier covers the cluster.
|
||||
fn os_families(script: ScriptTag) -> &'static [&'static str] {
|
||||
match &script {
|
||||
b"Arab" => &["Noto Naskh Arabic", "Noto Sans Arabic", "Geeza Pro", "SF Arabic", "DejaVu Sans"],
|
||||
b"Hebr" => &["Noto Sans Hebrew", "Arial Hebrew", "SF Hebrew", "DejaVu Sans"],
|
||||
b"Thai" => &["Noto Sans Thai", "Thonburi"],
|
||||
b"Deva" => &["Noto Sans Devanagari", "Kohinoor Devanagari", "Devanagari Sangam MN"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The craft-fonts families for a script (manifest order), e.g. Noto Naskh Arabic for `Arab`.
|
||||
pub fn bundled_families(script: ScriptTag) -> Vec<&'static str> {
|
||||
let tag = std::str::from_utf8(&script).unwrap_or("");
|
||||
let mut v: Vec<&'static str> = CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&tag)).map(|f| f.family).collect();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
impl FontDb {
|
||||
/// The face for one grapheme cluster: the first of `chain` that draws all of it, else the
|
||||
/// bundled face for its script, else an installed face for the script, else any loaded
|
||||
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
|
||||
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
|
||||
// Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the
|
||||
// document's fonts, as PowerPoint draws them, even when a text font has a plain glyph.
|
||||
if is_emoji_cluster(cluster) {
|
||||
if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
|
||||
&& let Some(f) = self.color_face_for(key, cluster)
|
||||
{
|
||||
return f;
|
||||
}
|
||||
}
|
||||
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
let faces = self.read_faces();
|
||||
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
}
|
||||
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
|
||||
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
|
||||
let script = cluster.iter().find_map(|c| script_of(*c));
|
||||
// Scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the bundled face,
|
||||
// then installed ones; the others keep the loaded-faces-first order (Japanese documents
|
||||
// prefer the bundled craft-fonts faces).
|
||||
if let Some(s) = script.filter(|s| !os_families(*s).is_empty()) {
|
||||
let bundled = bundled_families(s);
|
||||
for fam in &bundled {
|
||||
let f = self.face(fam, style);
|
||||
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
for fam in os_families(s) {
|
||||
if bundled.contains(fam) {
|
||||
continue;
|
||||
}
|
||||
if self.is_loaded(fam) || self.has_family(fam) {
|
||||
let f = self.face(fam, style);
|
||||
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(f) = self.system_face_for(key, cluster, &bundled) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX);
|
||||
match self.fallback_for(key, exclude) {
|
||||
Some(f) if covers_cluster(&f, cluster) => f,
|
||||
Some(f) if chain.iter().all(|c| !c.covers(key)) => f,
|
||||
_ => first(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An installed (non-bundled) face covering the cluster, loading one from the system catalog
|
||||
/// if no loaded face does.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_face_for(&self, key: char, cluster: &[char], bundled: &[&str]) -> Option<Arc<FontFace>> {
|
||||
let pick = |db: &FontDb| {
|
||||
let faces = db.read_faces();
|
||||
let mut v: Vec<&Arc<FontFace>> = faces
|
||||
.iter()
|
||||
.filter(|f| !bundled.iter().any(|b| f.family.eq_ignore_ascii_case(b)) && !CRAFT_FONTS.iter().any(|c| c.family == f.family))
|
||||
.filter(|f| covers_cluster(f, cluster))
|
||||
.collect();
|
||||
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
|
||||
v.first().map(|f| (*f).clone())
|
||||
};
|
||||
if let Some(f) = pick(self) {
|
||||
return Some(f);
|
||||
}
|
||||
if self.system_fallback(key) { pick(self) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
|
||||
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
|
||||
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
let Some(font) = face.skrifa() else { return Vec::new() };
|
||||
let Ok(gdef) = font.gdef() else { return Vec::new() };
|
||||
let Some(Ok(list)) = gdef.lig_caret_list() else { return Vec::new() };
|
||||
let Ok(cov) = list.coverage() else { return Vec::new() };
|
||||
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
|
||||
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
|
||||
let mut out: Vec<i32> = lig
|
||||
.caret_values()
|
||||
.iter()
|
||||
.filter_map(|cv| cv.ok())
|
||||
.filter_map(|cv| match cv {
|
||||
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
|
||||
skrifa::raw::tables::gdef::CaretValue::Format3(f) => Some(f.coordinate() as i32),
|
||||
// Format 2 is a contour point index; outlines aren't consulted here.
|
||||
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
|
||||
})
|
||||
.take(64)
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requested_face_wins_when_it_covers() {
|
||||
let db = FontDb::global();
|
||||
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
|
||||
let f = db.cascade(&['a'], std::slice::from_ref(&inter), "Regular");
|
||||
assert!(Arc::ptr_eq(&f, &inter));
|
||||
// Spaces and joiners never force a fallback.
|
||||
let f = db.cascade(&[' ', '\u{200D}'], std::slice::from_ref(&inter), "Regular");
|
||||
assert!(Arc::ptr_eq(&f, &inter));
|
||||
// Nothing to go on: the default face.
|
||||
assert!(!db.cascade(&[], &[], "Regular").family.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_cluster_falls_back_to_an_arabic_face() {
|
||||
let db = FontDb::global();
|
||||
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
|
||||
let cluster = ['\u{0628}', '\u{064E}']; // beh + fatha: one cluster, one face.
|
||||
let f = db.cascade(&cluster, std::slice::from_ref(&inter), "Regular");
|
||||
if covers_cluster(&f, &cluster) {
|
||||
assert!(f.covers('\u{0628}'));
|
||||
} else {
|
||||
eprintln!("skipped: no installed or bundled face covers Arabic");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligature_carets_never_fail() {
|
||||
let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");
|
||||
for gid in [0, 1, 100, u32::MAX] {
|
||||
let c = ligature_carets(&face, gid);
|
||||
assert!(c.windows(2).all(|w| w[0] <= w[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
-15
@@ -52,6 +52,53 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
|
||||
/// them breaks into the same lines.
|
||||
fn has_metric_twin(family: &str) -> bool {
|
||||
matches!(
|
||||
family.to_ascii_lowercase().as_str(),
|
||||
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
|
||||
)
|
||||
}
|
||||
|
||||
/// The installed family PowerPoint draws a missing `family` with instead of Calibri: Liberation
|
||||
/// Sans and Serif as Arial and Times New Roman (observed in PowerPoint; Liberation Mono, Carlito,
|
||||
/// Caladea, Arimo and Tinos are not mapped and draw in Calibri), and Windows' standard font
|
||||
/// substitutes (Arial CE, Times New Roman CYR…).
|
||||
fn office_twin(family: &str) -> Option<&'static str> {
|
||||
let f = family.to_ascii_lowercase();
|
||||
Some(match f.as_str() {
|
||||
"liberation sans" | "helvetica" | "helv" => "Arial",
|
||||
"liberation serif" | "times" | "tms rmn" => "Times New Roman",
|
||||
_ => {
|
||||
let base = f
|
||||
.strip_suffix(" baltic")
|
||||
.or(f.strip_suffix(" ce"))
|
||||
.or(f.strip_suffix(" cyr"))
|
||||
.or(f.strip_suffix(" greek"))
|
||||
.or(f.strip_suffix(" tur"))?;
|
||||
match base {
|
||||
"arial" => "Arial",
|
||||
"courier new" => "Courier New",
|
||||
"times new roman" => "Times New Roman",
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Office's downloadable (cloud) families: PowerPoint fetches them when a document uses them, so
|
||||
/// they draw as themselves there, not as Calibri. Without them DeckCraft keeps their closest open
|
||||
/// substitute ([`substitutes`]; Aptos as Inter).
|
||||
fn is_office_cloud_font(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
f.starts_with("aptos") || ["bierstadt", "grandview", "seaford", "skeena", "tenorite"].iter().any(|c| f.starts_with(c))
|
||||
}
|
||||
|
||||
/// The family PowerPoint draws a document's Latin text in when the font it names isn't installed,
|
||||
/// whatever the theme, `panose` or `pitchFamily` say.
|
||||
pub const MISSING_FONT_FAMILY: &str = "Calibri";
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FontBytes {
|
||||
Static(&'static [u8]),
|
||||
@@ -65,6 +112,10 @@ pub struct FontFace {
|
||||
pub family: String,
|
||||
/// Typographic style name (e.g. "Semibold", "Italic").
|
||||
pub style: String,
|
||||
/// The Windows (GDI) family and style names (`name` IDs 1 and 2) when the family differs from
|
||||
/// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name
|
||||
/// fonts this way.
|
||||
pub legacy: Option<(String, String)>,
|
||||
/// usWeightClass-style weight (400 = regular).
|
||||
pub weight: f32,
|
||||
pub italic: bool,
|
||||
@@ -79,6 +130,10 @@ pub struct FontFace {
|
||||
/// values).
|
||||
pub cap_height: f64,
|
||||
pub x_height: f64,
|
||||
/// Where the baseline sits in a line, as a share of the line's height from its top: the
|
||||
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
|
||||
/// the split PowerPoint uses (computed by `line_split`).
|
||||
pub line_split: f64,
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
|
||||
/// the normalised location and the shaper's view of it.
|
||||
@@ -87,6 +142,8 @@ pub struct FontFace {
|
||||
pub instance: Option<harfrust::ShaperInstance>,
|
||||
/// Basic Multilingual Plane coverage bitset, built on first use.
|
||||
bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||
/// Colour glyph tables present (`is_color`), looked up on first use.
|
||||
color: std::sync::OnceLock<bool>,
|
||||
}
|
||||
|
||||
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
|
||||
@@ -154,6 +211,11 @@ impl FontFace {
|
||||
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
|
||||
skrifa::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
/// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji
|
||||
/// fonts have)?
|
||||
pub fn is_color(&self) -> bool {
|
||||
*self.color.get_or_init(|| has_color_tables(self.data(), self.index))
|
||||
}
|
||||
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
||||
harfrust::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
@@ -242,6 +304,11 @@ pub struct FontDb {
|
||||
/// scan, so what they find doesn't depend on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// Families (lower case) whose cataloged files have all been loaded: a family's files load
|
||||
/// together, so a lookup never settles for the one face another name loaded ("Segoe UI
|
||||
/// Semibold" loads a face of the family "Segoe UI").
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
loaded_families: Mutex<std::collections::HashSet<String>>,
|
||||
/// System fallback state: enabled, and characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex<SysFallback>,
|
||||
@@ -254,6 +321,13 @@ struct SysFallback {
|
||||
misses: std::collections::HashSet<char>,
|
||||
}
|
||||
|
||||
/// Does face `index` of `data` carry colour glyph tables?
|
||||
fn has_color_tables(data: &[u8], index: u32) -> bool {
|
||||
use skrifa::raw::types::Tag;
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false };
|
||||
[b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some())
|
||||
}
|
||||
|
||||
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
@@ -298,6 +372,16 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
Some((family, style))
|
||||
}
|
||||
|
||||
/// A face's Windows (GDI) family and style (`name` IDs 1 and 2) when that family differs from the
|
||||
/// typographic `family`.
|
||||
fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String)> {
|
||||
let legacy = name(f, &[StringId::FAMILY_NAME])?;
|
||||
if legacy.eq_ignore_ascii_case(family) {
|
||||
return None;
|
||||
}
|
||||
Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into())))
|
||||
}
|
||||
|
||||
/// (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. A
|
||||
/// variable font is cataloged under its default style; its named instances appear once it loads.
|
||||
@@ -344,8 +428,13 @@ fn file_face_names(path: &std::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)?;
|
||||
// Cataloged under its Windows family too, so "Segoe UI Semibold" finds the file.
|
||||
let legacy = legacy_names(&f, &family);
|
||||
Some(std::iter::once((family, style)).chain(legacy))
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -361,6 +450,12 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
// Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them.
|
||||
dirs.push(h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts"));
|
||||
}
|
||||
// Fonts Office installs inside its apps.
|
||||
for app in ["Microsoft PowerPoint", "Microsoft Word", "Microsoft Excel"] {
|
||||
dirs.push(std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts")));
|
||||
}
|
||||
// Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
|
||||
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
||||
@@ -375,7 +470,11 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
let l = std::path::PathBuf::from(l);
|
||||
dirs.push(l.join("Microsoft\\Windows\\Fonts"));
|
||||
// Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them;
|
||||
// DeckCraft never ships or needs them.
|
||||
dirs.push(l.join("Microsoft\\FontCache\\4\\CloudFonts"));
|
||||
}
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
@@ -436,6 +535,23 @@ pub(crate) fn last_resort_face() -> Arc<FontFace> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The share of a line above the baseline (`FontFace::line_split`): OS/2 `usWinAscent` over
|
||||
/// `usWinAscent + usWinDescent`, else the ascent over ascent plus descent. Fonts whose Windows
|
||||
/// extent is huge (Gabriola, Cambria Math: over 1.5 em) get 0.81, where PowerPoint puts their
|
||||
/// baseline (observed).
|
||||
fn line_split(f: &skrifa::FontRef, ascent: f64, descent: f64, upem: f64) -> f64 {
|
||||
use skrifa::raw::TableProvider;
|
||||
let (a, d) = match f.os2() {
|
||||
Ok(os2) if os2.us_win_ascent() > 0 => (os2.us_win_ascent() as f64, os2.us_win_descent() as f64),
|
||||
_ => (ascent, descent),
|
||||
};
|
||||
let total = a + d;
|
||||
if !total.is_finite() || total <= 0.0 || a < 0.0 || d < 0.0 {
|
||||
return 0.8;
|
||||
}
|
||||
if total > upem * 1.5 { 0.81 } else { (a / total).clamp(0.5, 1.0) }
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
@@ -456,10 +572,12 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
.map(|v| v >= 0.5)
|
||||
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
|
||||
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
|
||||
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None };
|
||||
Some(FontFace {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
legacy,
|
||||
weight,
|
||||
italic,
|
||||
upem: m.units_per_em.max(1) as f64,
|
||||
@@ -467,6 +585,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
descent: -(m.descent as f64),
|
||||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
|
||||
shaper,
|
||||
coords,
|
||||
location,
|
||||
@@ -474,6 +593,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
bytes,
|
||||
index,
|
||||
bmp: std::sync::OnceLock::new(),
|
||||
color: std::sync::OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -534,6 +654,8 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
loaded_families: Mutex::new(std::collections::HashSet::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
@@ -557,7 +679,7 @@ impl FontDb {
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
@@ -639,6 +761,8 @@ impl FontDb {
|
||||
/// Returns the number of faces cataloged.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// Families load again from the new catalog when next looked up.
|
||||
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
@@ -719,17 +843,35 @@ impl FontDb {
|
||||
.unwrap_or_else(last_resort_face)
|
||||
}
|
||||
|
||||
/// The face a document's text in `family` is drawn with, as PowerPoint draws it: the family
|
||||
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
|
||||
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
|
||||
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
|
||||
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
|
||||
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
|
||||
return self.face(family, style);
|
||||
}
|
||||
if let Some(twin) = office_twin(family) {
|
||||
return self.face(twin, style);
|
||||
}
|
||||
self.face(MISSING_FONT_FAMILY, style)
|
||||
}
|
||||
|
||||
fn try_family(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.find(family, style) {
|
||||
return Some(f);
|
||||
}
|
||||
// Every installed file of the family first, so the best style is among the candidates. The
|
||||
// lock is held while the files load, so another thread looking up the family waits for
|
||||
// them rather than finding none and falling back to another family.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.load_cataloged(family)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return Some(f);
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = family.to_lowercase();
|
||||
if !loaded.contains(&key) {
|
||||
self.load_cataloged(family);
|
||||
loaded.insert(key);
|
||||
}
|
||||
}
|
||||
None
|
||||
self.find(family, style)
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
@@ -744,8 +886,10 @@ impl FontDb {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces()
|
||||
.iter()
|
||||
.any(|f| f.family.eq_ignore_ascii_case(family) || f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family)))
|
||||
}
|
||||
|
||||
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
|
||||
@@ -800,11 +944,27 @@ impl FontDb {
|
||||
return self.instance(family, style);
|
||||
}
|
||||
let faces = self.read_faces();
|
||||
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
let mut cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
let ns = norm(style);
|
||||
if cands.is_empty() {
|
||||
// A Windows family name ("Segoe UI Semibold", "Arial Black"): its faces, matched by
|
||||
// their Windows style ("Regular", "Bold"…).
|
||||
cands = faces.iter().filter(|f| f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))).collect();
|
||||
if let Some(f) = cands.iter().find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == ns)) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
// Bold of a family that is itself a weight (Semibold, Light): the face, made bolder
|
||||
// by the caller, as Windows does, rather than a face of another weight.
|
||||
if let Some(f) = cands
|
||||
.iter()
|
||||
.find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == norm(if style_italic(style) { "Italic" } else { "Regular" })))
|
||||
{
|
||||
return Some((*f).clone());
|
||||
}
|
||||
}
|
||||
if cands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ns = norm(style);
|
||||
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
@@ -842,7 +1002,7 @@ impl FontDb {
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_fallback(&self, c: char) -> bool {
|
||||
pub(crate) fn system_fallback(&self, c: char) -> bool {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
@@ -880,6 +1040,61 @@ impl FontDb {
|
||||
false
|
||||
}
|
||||
|
||||
/// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers
|
||||
/// `key` if none is loaded yet. Misses are remembered.
|
||||
pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
|
||||
let pick = |db: &FontDb| {
|
||||
let faces = db.read_faces();
|
||||
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect();
|
||||
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
|
||||
v.first().map(|f| (*f).clone())
|
||||
};
|
||||
if let Some(f) = pick(self) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_color_fallback(key) {
|
||||
return pick(self);
|
||||
}
|
||||
let _ = key;
|
||||
None
|
||||
}
|
||||
|
||||
/// Load an installed colour font covering `c`. True if one was loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_color_fallback(&self, c: char) -> bool {
|
||||
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !sys.enabled {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
if std::fs::metadata(&p).map(|m| m.len() > 64 << 20).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
|
||||
has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
|
||||
});
|
||||
if hit && self.add_font(data) > 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
|
||||
+122
-84
@@ -1,21 +1,27 @@
|
||||
//! DeckCraft fonts: the font database (craft-fonts families, user and system fonts, and open
|
||||
//! substitutes for the Office fonts presentations ask for), vertical metrics, glyph outlines and
|
||||
//! OpenType shaping.
|
||||
//! OpenType shaping, script/slot classification and per-cluster fallback.
|
||||
//!
|
||||
//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters
|
||||
//! and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
|
||||
//! Shaping here is style-agnostic: [`shape`] and [`shape_range`] turn text in one face into glyph
|
||||
//! ids, clusters and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
|
||||
//! justification on top.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod fallback;
|
||||
mod fontdb;
|
||||
pub mod script;
|
||||
mod shape;
|
||||
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
|
||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
|
||||
};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use harfrust::Tag;
|
||||
pub use kurbo::BezPath;
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use script::{FontSlot, ScriptTag};
|
||||
pub use shape::{ShapeParams, ShapedGlyph, shape_range};
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
@@ -53,17 +59,6 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
||||
/// The default theme's font.
|
||||
pub const DEFAULT_FAMILY: &str = "Inter";
|
||||
|
||||
/// One shaped glyph, in font units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ShapedGlyph {
|
||||
pub gid: u32,
|
||||
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
|
||||
pub cluster: usize,
|
||||
pub x_advance: i32,
|
||||
pub x_offset: i32,
|
||||
pub y_offset: i32,
|
||||
}
|
||||
|
||||
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
|
||||
pub fn feature(tag: &str) -> Option<Feature> {
|
||||
let (on, t) = match tag.strip_prefix('-') {
|
||||
@@ -83,6 +78,26 @@ pub fn is_rtl(c: char) -> bool {
|
||||
matches!(unicode_bidi::bidi_class(c), R | AL)
|
||||
}
|
||||
|
||||
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
|
||||
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
|
||||
/// order: each direction run is shaped with [`shape_range`] (right-to-left runs right to left,
|
||||
/// with the whole string as context), then its clusters put back in text order (each cluster's
|
||||
/// glyphs keep the shaper's order). Paragraph layout uses [`shape_range`] directly and keeps the
|
||||
/// visual order.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let mut g = shape_range(face, text, r, &ShapeParams { features, rtl, ..Default::default() }, &map);
|
||||
if rtl {
|
||||
// Visual (clusters descending) → logical; the sort is stable, so each cluster's
|
||||
// glyphs keep the shaper's order.
|
||||
g.sort_by_key(|x| x.cluster);
|
||||
}
|
||||
out.extend(g);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
|
||||
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
|
||||
@@ -110,72 +125,6 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
runs
|
||||
}
|
||||
|
||||
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
|
||||
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
|
||||
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
|
||||
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
if !text.chars().any(is_rtl) {
|
||||
return shape_dir(face, text, features, &map, false);
|
||||
}
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl);
|
||||
for x in &mut g {
|
||||
x.cluster += r.start;
|
||||
}
|
||||
if rtl {
|
||||
// Visual (clusters descending) → logical, cluster by cluster.
|
||||
let mut groups: Vec<Vec<ShapedGlyph>> = Vec::new();
|
||||
for x in g {
|
||||
match groups.last_mut() {
|
||||
Some(last) if last[0].cluster == x.cluster => last.push(x),
|
||||
_ => groups.push(vec![x]),
|
||||
}
|
||||
}
|
||||
groups.sort_by_key(|grp| grp[0].cluster);
|
||||
g = groups.into_iter().flatten().collect();
|
||||
}
|
||||
out.extend(g);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), i as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
});
|
||||
}
|
||||
});
|
||||
if shaped.is_none()
|
||||
&& let Some(f) = face.skrifa()
|
||||
{
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Glyph id of the first of `chars` the face has (0 = .notdef).
|
||||
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
|
||||
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
|
||||
@@ -189,6 +138,25 @@ mod tests {
|
||||
FontDb::global().face(DEFAULT_FAMILY, "Regular")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_clusters_are_recognised() {
|
||||
let c = |s: &str| s.chars().collect::<Vec<char>>();
|
||||
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
|
||||
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
|
||||
}
|
||||
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
|
||||
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_fonts_are_recognised() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0);
|
||||
assert!(db.face("DeckCraft Colr Squares", "Regular").is_color());
|
||||
assert!(!fontdb::last_resort_face().is_color());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_resort_face_parses() {
|
||||
let f = fontdb::last_resort_face();
|
||||
@@ -196,6 +164,17 @@ mod tests {
|
||||
assert!(f.upem > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_split_follows_the_windows_ascent_and_descent() {
|
||||
use skrifa::raw::TableProvider;
|
||||
let f = fontdb::last_resort_face();
|
||||
let font = skrifa::FontRef::new(f.data()).unwrap();
|
||||
let os2 = font.os2().unwrap();
|
||||
let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64);
|
||||
assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d));
|
||||
assert!(f.line_split > 0.5 && f.line_split < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn some_face_always_resolves() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
@@ -205,6 +184,65 @@ mod tests {
|
||||
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_family_names_find_their_faces() {
|
||||
// Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's
|
||||
// typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way.
|
||||
let check = |db: &FontDb| {
|
||||
assert!(db.has_family("DeckCraft Legacy Semibold"));
|
||||
for style in ["Regular", "Bold"] {
|
||||
let f = db.face("DeckCraft Legacy Semibold", style);
|
||||
assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}");
|
||||
}
|
||||
assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri");
|
||||
// The typographic name still works.
|
||||
assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold");
|
||||
};
|
||||
// Loaded directly…
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1);
|
||||
check(&db);
|
||||
// …and found by the system font scan under its Windows name, then loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
db.set_system_fallback(false);
|
||||
check(&db);
|
||||
// Looking up the Windows name loads the Semibold file only; a later lookup of the
|
||||
// family's Regular must still load and pick the Regular file, not settle for Semibold.
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold");
|
||||
let regular = db.face("DeckCraft Legacy", "Regular");
|
||||
assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_document_fonts_are_drawn_in_calibri() {
|
||||
// PowerPoint draws a missing family in Calibri, whatever its panose or the theme says.
|
||||
let db = FontDb::global();
|
||||
for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] {
|
||||
let f = db.document_face(missing, "Bold");
|
||||
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
|
||||
}
|
||||
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
|
||||
for (missing, twin) in
|
||||
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
|
||||
{
|
||||
if !db.has_family(missing) {
|
||||
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
|
||||
}
|
||||
}
|
||||
// Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks.
|
||||
// So do Office's cloud fonts, which PowerPoint downloads and draws as themselves.
|
||||
for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] {
|
||||
assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_fonts_have_substitutes() {
|
||||
assert!(substitutes("Calibri").contains(&"Carlito"));
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Script and font-slot classification.
|
||||
//!
|
||||
//! DrawingML gives every run three typefaces: `a:latin`, `a:ea` (East Asian) and `a:cs` (complex
|
||||
//! script: Arabic, Hebrew, Thai, Indic…). Which one a character uses follows its Unicode script
|
||||
//! (UAX #24). Characters with no script of their own (Common and Inherited: spaces, digits,
|
||||
//! punctuation, combining marks) take the script and slot of the text around them, so a space or
|
||||
//! a parenthesis between two Arabic words is shaped with them.
|
||||
|
||||
/// An ISO 15924 script tag, e.g. `*b"Arab"`.
|
||||
pub type ScriptTag = [u8; 4];
|
||||
|
||||
pub const ARABIC: ScriptTag = *b"Arab";
|
||||
pub const HEBREW: ScriptTag = *b"Hebr";
|
||||
pub const LATIN: ScriptTag = *b"Latn";
|
||||
|
||||
/// The DrawingML typeface slot a character is drawn with.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum FontSlot {
|
||||
/// `a:latin`.
|
||||
#[default]
|
||||
Latin,
|
||||
/// `a:ea`.
|
||||
EastAsian,
|
||||
/// `a:cs`.
|
||||
ComplexScript,
|
||||
}
|
||||
|
||||
/// Inclusive code point ranges and their script (a subset of `Scripts.txt` covering the scripts
|
||||
/// the slot decision and shaping need; everything else is Latin-slot or Common).
|
||||
const RANGES: &[(u32, u32, ScriptTag)] = &[
|
||||
(0x0041, 0x005A, *b"Latn"),
|
||||
(0x0061, 0x007A, *b"Latn"),
|
||||
(0x00AA, 0x00AA, *b"Latn"),
|
||||
(0x00BA, 0x00BA, *b"Latn"),
|
||||
(0x00C0, 0x00D6, *b"Latn"),
|
||||
(0x00D8, 0x00F6, *b"Latn"),
|
||||
(0x00F8, 0x024F, *b"Latn"),
|
||||
(0x0370, 0x03FF, *b"Grek"),
|
||||
(0x0400, 0x052F, *b"Cyrl"),
|
||||
(0x0530, 0x058F, *b"Armn"),
|
||||
(0x0591, 0x05F4, *b"Hebr"),
|
||||
// Arabic block, minus the Common/Inherited code points handled in `script_of`.
|
||||
(0x0600, 0x06FF, *b"Arab"),
|
||||
(0x0700, 0x074F, *b"Syrc"),
|
||||
(0x0750, 0x077F, *b"Arab"),
|
||||
(0x0780, 0x07BF, *b"Thaa"),
|
||||
(0x07C0, 0x07FF, *b"Nkoo"),
|
||||
(0x0870, 0x08FF, *b"Arab"),
|
||||
(0x0900, 0x097F, *b"Deva"),
|
||||
(0x0980, 0x09FF, *b"Beng"),
|
||||
(0x0A00, 0x0A7F, *b"Guru"),
|
||||
(0x0A80, 0x0AFF, *b"Gujr"),
|
||||
(0x0B00, 0x0B7F, *b"Orya"),
|
||||
(0x0B80, 0x0BFF, *b"Taml"),
|
||||
(0x0C00, 0x0C7F, *b"Telu"),
|
||||
(0x0C80, 0x0CFF, *b"Knda"),
|
||||
(0x0D00, 0x0D7F, *b"Mlym"),
|
||||
(0x0D80, 0x0DFF, *b"Sinh"),
|
||||
(0x0E00, 0x0E7F, *b"Thai"),
|
||||
(0x0E80, 0x0EFF, *b"Laoo"),
|
||||
(0x0F00, 0x0FFF, *b"Tibt"),
|
||||
(0x1000, 0x109F, *b"Mymr"),
|
||||
(0x10A0, 0x10FF, *b"Geor"),
|
||||
(0x1100, 0x11FF, *b"Hang"),
|
||||
(0x1200, 0x139F, *b"Ethi"),
|
||||
(0x1780, 0x17FF, *b"Khmr"),
|
||||
(0x1E00, 0x1EFF, *b"Latn"),
|
||||
(0x1F00, 0x1FFF, *b"Grek"),
|
||||
(0x2C60, 0x2C7F, *b"Latn"),
|
||||
(0x2E80, 0x2FDF, *b"Hani"),
|
||||
(0x3040, 0x309F, *b"Hira"),
|
||||
(0x30A0, 0x30FF, *b"Kana"),
|
||||
(0x3100, 0x312F, *b"Bopo"),
|
||||
(0x3130, 0x318F, *b"Hang"),
|
||||
(0x31F0, 0x31FF, *b"Kana"),
|
||||
(0x3400, 0x4DBF, *b"Hani"),
|
||||
(0x4E00, 0x9FFF, *b"Hani"),
|
||||
(0xA720, 0xA7FF, *b"Latn"),
|
||||
(0xAC00, 0xD7AF, *b"Hang"),
|
||||
(0xF900, 0xFAFF, *b"Hani"),
|
||||
(0xFB00, 0xFB06, *b"Latn"),
|
||||
(0xFB1D, 0xFB4F, *b"Hebr"),
|
||||
(0xFB50, 0xFDFF, *b"Arab"),
|
||||
(0xFE70, 0xFEFF, *b"Arab"),
|
||||
(0xFF21, 0xFF3A, *b"Latn"),
|
||||
(0xFF41, 0xFF5A, *b"Latn"),
|
||||
(0xFF66, 0xFF9F, *b"Kana"),
|
||||
(0x10E60, 0x10E7F, *b"Arab"),
|
||||
(0x1EE00, 0x1EEFF, *b"Arab"),
|
||||
(0x20000, 0x323AF, *b"Hani"),
|
||||
];
|
||||
|
||||
/// Code points inside the Arabic blocks whose script is Common or Inherited: the Arabic comma,
|
||||
/// semicolon and question mark, tatweel, the harakat (combining vowel marks) and the BOM-like
|
||||
/// U+FEFF. They take the script of the text around them.
|
||||
fn arabic_common(cp: u32) -> bool {
|
||||
matches!(cp, 0x060C | 0x061B | 0x061F | 0x0640 | 0x064B..=0x0655 | 0x0670 | 0xFEFF | 0xFD3E | 0xFD3F)
|
||||
}
|
||||
|
||||
/// The script of `c`, or `None` for Common and Inherited characters (spaces, digits,
|
||||
/// punctuation, combining marks), which take the script of their neighbours.
|
||||
pub fn script_of(c: char) -> Option<ScriptTag> {
|
||||
let cp = c as u32;
|
||||
if arabic_common(cp) {
|
||||
return None;
|
||||
}
|
||||
// Ranges are sorted and disjoint: binary search on the start.
|
||||
let i = RANGES.partition_point(|(lo, _, _)| *lo <= cp);
|
||||
let (lo, hi, tag) = RANGES.get(i.checked_sub(1)?)?;
|
||||
(cp >= *lo && cp <= *hi).then_some(*tag)
|
||||
}
|
||||
|
||||
/// The slot a script is drawn with.
|
||||
pub fn slot_of_script(s: ScriptTag) -> FontSlot {
|
||||
match &s {
|
||||
b"Hani" | b"Hira" | b"Kana" | b"Hang" | b"Bopo" => FontSlot::EastAsian,
|
||||
b"Arab" | b"Hebr" | b"Syrc" | b"Thaa" | b"Nkoo" | b"Deva" | b"Beng" | b"Guru" | b"Gujr" | b"Orya" | b"Taml" | b"Telu" | b"Knda" | b"Mlym"
|
||||
| b"Sinh" | b"Thai" | b"Laoo" | b"Tibt" | b"Mymr" | b"Khmr" => FontSlot::ComplexScript,
|
||||
_ => FontSlot::Latin,
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot of `c` on its own: `None` for characters that take their neighbours' slot. CJK
|
||||
/// punctuation and full-width forms are East Asian although their script is Common.
|
||||
pub fn slot_of(c: char) -> Option<FontSlot> {
|
||||
if let Some(s) = script_of(c) {
|
||||
return Some(slot_of_script(s));
|
||||
}
|
||||
let cp = c as u32;
|
||||
if (0x3000..=0x303F).contains(&cp) || (0xFF00..=0xFFEF).contains(&cp) || (0x3200..=0x33FF).contains(&cp) {
|
||||
return Some(FontSlot::EastAsian);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A default BCP 47 language for a script (shaping picks `locl` forms by language).
|
||||
pub fn default_language(s: ScriptTag) -> Option<&'static str> {
|
||||
Some(match &s {
|
||||
b"Arab" => "ar",
|
||||
b"Hebr" => "he",
|
||||
b"Syrc" => "syr",
|
||||
b"Thaa" => "dv",
|
||||
b"Thai" => "th",
|
||||
b"Deva" => "hi",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve every character's script and slot, giving Common/Inherited characters the value of the
|
||||
/// preceding character with one (or, at the start, the following one; Latin when there is none).
|
||||
pub fn resolve(chars: &[char]) -> Vec<(Option<ScriptTag>, FontSlot)> {
|
||||
let own: Vec<(Option<ScriptTag>, Option<FontSlot>)> = chars.iter().map(|c| (script_of(*c), slot_of(*c))).collect();
|
||||
let first = own.iter().find_map(|(s, sl)| sl.map(|sl| (*s, sl))).unwrap_or((None, FontSlot::Latin));
|
||||
let mut prev = first;
|
||||
own.into_iter()
|
||||
.map(|(s, sl)| {
|
||||
if let Some(sl) = sl {
|
||||
prev = (s, sl);
|
||||
}
|
||||
prev
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Arabic letters that join to the following letter (joining type D, dual-joining): a kashida
|
||||
/// (tatweel) may be inserted after them. Right-joining (alef, dal, reh, waw…) and non-joining
|
||||
/// (hamza) letters are excluded.
|
||||
pub fn joins_following(c: char) -> bool {
|
||||
let cp = c as u32;
|
||||
let letter = matches!(cp, 0x0620..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9);
|
||||
letter
|
||||
&& !matches!(
|
||||
cp,
|
||||
0x0621..=0x0625 | 0x0627 | 0x0629 | 0x062F..=0x0632 | 0x0648 | 0x0671..=0x0673 | 0x0675..=0x0677 | 0x0688..=0x0699 | 0x06C0
|
||||
| 0x06C3..=0x06CB | 0x06CD | 0x06CF | 0x06D2 | 0x06D3 | 0x0759..=0x075B | 0x076B | 0x076C | 0x0771 | 0x0773 | 0x0774
|
||||
| 0x0778 | 0x0779 | 0x08AA..=0x08AC | 0x08AE | 0x08B1 | 0x08B2 | 0x08B9
|
||||
)
|
||||
}
|
||||
|
||||
/// An Arabic letter that joins to the preceding letter (joining type D or R).
|
||||
pub fn joins_preceding(c: char) -> bool {
|
||||
let cp = c as u32;
|
||||
matches!(cp, 0x0622..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9) && cp != 0x0621
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scripts_and_slots() {
|
||||
assert_eq!(script_of('س'), Some(ARABIC));
|
||||
assert_eq!(script_of('ש'), Some(HEBREW));
|
||||
assert_eq!(script_of('R'), Some(LATIN));
|
||||
assert_eq!(script_of('2'), None);
|
||||
assert_eq!(script_of('('), None);
|
||||
// Harakat and tatweel are Inherited/Common.
|
||||
assert_eq!(script_of('\u{064E}'), None);
|
||||
assert_eq!(script_of('\u{0640}'), None);
|
||||
assert_eq!(slot_of('س'), Some(FontSlot::ComplexScript));
|
||||
assert_eq!(slot_of('日'), Some(FontSlot::EastAsian));
|
||||
assert_eq!(slot_of('、'), Some(FontSlot::EastAsian));
|
||||
assert_eq!(slot_of('a'), Some(FontSlot::Latin));
|
||||
assert_eq!(slot_of(' '), None);
|
||||
assert_eq!(script_of('\u{10FFFF}'), None);
|
||||
assert_eq!(script_of('\0'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutrals_take_the_surrounding_slot() {
|
||||
let chars: Vec<char> = "سنة 2024 (Retrait) فقط".chars().collect();
|
||||
let r = resolve(&chars);
|
||||
// "سنة 2024 (" follows Arabic: complex script.
|
||||
for (i, c) in chars.iter().enumerate().take(10) {
|
||||
assert_eq!(r[i].1, FontSlot::ComplexScript, "{c:?}");
|
||||
}
|
||||
// "Retrait)" is Latin; the space after ")" stays Latin; "فقط" is complex script.
|
||||
assert_eq!(r[10].1, FontSlot::Latin);
|
||||
assert_eq!(r[17].1, FontSlot::Latin);
|
||||
assert_eq!(r[19], (Some(ARABIC), FontSlot::ComplexScript));
|
||||
// Leading neutrals take the first strong character's slot.
|
||||
let r = resolve(&['(', 'س']);
|
||||
assert_eq!(r[0].1, FontSlot::ComplexScript);
|
||||
assert!(resolve(&[]).is_empty());
|
||||
assert_eq!(resolve(&['1'])[0], (None, FontSlot::Latin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_classes() {
|
||||
assert!(joins_following('س'));
|
||||
assert!(joins_following('ن'));
|
||||
assert!(!joins_following('ا'));
|
||||
assert!(!joins_following('د'));
|
||||
assert!(!joins_following('ء'));
|
||||
assert!(joins_preceding('ا'));
|
||||
assert!(!joins_preceding('ء'));
|
||||
assert!(!joins_following('a'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_are_sorted_and_disjoint() {
|
||||
for w in RANGES.windows(2) {
|
||||
assert!(w[0].1 < w[1].0, "{:x?} {:x?}", w[0], w[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! OpenType shaping with HarfRust.
|
||||
//!
|
||||
//! [`shape_range`] shapes one item of a paragraph (one face, one direction, one script) with the
|
||||
//! rest of the paragraph as pre/post context, so Arabic letters at an item boundary (a colour
|
||||
//! change inside a word, a bold letter) still take their joining forms. Glyphs come back in
|
||||
//! visual order, exactly as the shaper produced them: right-to-left items are not reversed.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use harfrust::{Direction, Feature, Language, Script, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
|
||||
use crate::FontFace;
|
||||
use crate::script::ScriptTag;
|
||||
|
||||
/// One shaped glyph, in font units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ShapedGlyph {
|
||||
pub gid: u32,
|
||||
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
|
||||
pub cluster: usize,
|
||||
pub x_advance: i32,
|
||||
pub x_offset: i32,
|
||||
pub y_offset: i32,
|
||||
}
|
||||
|
||||
/// How to shape an item.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ShapeParams<'a> {
|
||||
pub features: &'a [Feature],
|
||||
/// Right-to-left (odd bidi level). Mirrored characters (brackets) take their mirrored glyphs.
|
||||
pub rtl: bool,
|
||||
/// ISO 15924 script (`*b"Arab"`); `None` lets the shaper guess from the text.
|
||||
pub script: Option<ScriptTag>,
|
||||
/// BCP 47 language (`"ar"`, `"ar-MA"`); `None` = the script's default.
|
||||
pub language: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
|
||||
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
|
||||
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
|
||||
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.set_pre_context(pre);
|
||||
buf.set_post_context(post);
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), (range.start + i) as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
||||
buf.set_script(s);
|
||||
}
|
||||
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
|
||||
{
|
||||
buf.set_language(l);
|
||||
}
|
||||
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
});
|
||||
}
|
||||
});
|
||||
if shaped.is_none()
|
||||
&& let Some(f) = face.skrifa()
|
||||
{
|
||||
// No shaper for this face: one glyph per character from the cmap, in visual order.
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
}
|
||||
if params.rtl {
|
||||
out.reverse();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{DEFAULT_FAMILY, FontDb};
|
||||
|
||||
#[test]
|
||||
fn rtl_items_come_in_visual_order_and_mirror() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let text = "(ab)";
|
||||
let p = ShapeParams { rtl: true, ..Default::default() };
|
||||
let g = shape_range(&face, text, 0..text.len(), &p, |c| c);
|
||||
// Visual order: last cluster first.
|
||||
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![3, 2, 1, 0]);
|
||||
// L4: in a right-to-left run "(" is drawn with the ")" glyph and vice versa.
|
||||
if face.covers('(') && face.covers(')') {
|
||||
assert_eq!(g[3].gid, face.glyph_for(')'));
|
||||
assert_eq!(g[0].gid, face.glyph_for('('));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clusters_point_into_the_whole_paragraph() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let para = "Hello world";
|
||||
let g = shape_range(&face, para, 6..11, &ShapeParams::default(), |c| c);
|
||||
assert_eq!(g.first().map(|g| g.cluster), Some(6));
|
||||
assert!(shape_range(&face, para, 6..99, &ShapeParams::default(), |c| c).is_empty());
|
||||
// Not a char boundary.
|
||||
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+55
@@ -0,0 +1,55 @@
|
||||
"""Build the legacy-name test font: DeckCraft Legacy Semibold (contributor-original, CC0-1.0).
|
||||
|
||||
Usage: python make_legacy_font.py legacy-semibold.ttf legacy-regular.ttf (needs fontTools)
|
||||
|
||||
legacy-semibold.ttf: typographic family "DeckCraft Legacy", style "Semibold" (name IDs 16/17,
|
||||
weight 600); Windows family "DeckCraft Legacy Semibold", style "Regular" (name IDs 1/2), as fonts
|
||||
like Segoe UI Semibold are named. legacy-regular.ttf: the family's Regular ("DeckCraft Legacy",
|
||||
"Regular", weight 400), named only by IDs 1/2 as Segoe UI's regular file is. Glyphs: .notdef and
|
||||
a square for "A".
|
||||
"""
|
||||
import sys
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
|
||||
|
||||
def square():
|
||||
pen = TTGlyphPen(None)
|
||||
pen.moveTo((100, 0))
|
||||
pen.lineTo((100, 700))
|
||||
pen.lineTo((600, 700))
|
||||
pen.lineTo((600, 0))
|
||||
pen.closePath()
|
||||
return pen.glyph()
|
||||
|
||||
|
||||
def build(path, names, weight):
|
||||
fb = FontBuilder(1000, isTTF=True)
|
||||
fb.setupGlyphOrder([".notdef", "A"])
|
||||
fb.setupCharacterMap({ord("A"): "A"})
|
||||
fb.setupGlyf({".notdef": TTGlyphPen(None).glyph(), "A": square()})
|
||||
fb.setupHorizontalMetrics({".notdef": (500, 0), "A": (700, 100)})
|
||||
fb.setupHorizontalHeader(ascent=800, descent=-200)
|
||||
fb.setupNameTable(names)
|
||||
fb.setupOS2(usWeightClass=weight, sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200)
|
||||
fb.setupPost()
|
||||
fb.save(path)
|
||||
|
||||
|
||||
build(sys.argv[1], {
|
||||
"familyName": "DeckCraft Legacy Semibold",
|
||||
"styleName": "Regular",
|
||||
"typographicFamily": "DeckCraft Legacy",
|
||||
"typographicSubfamily": "Semibold",
|
||||
"uniqueFontIdentifier": "DeckCraft Legacy Semibold",
|
||||
"fullName": "DeckCraft Legacy Semibold",
|
||||
"psName": "DeckCraftLegacy-Semibold",
|
||||
}, 600)
|
||||
build(sys.argv[2], {
|
||||
"familyName": "DeckCraft Legacy",
|
||||
"styleName": "Regular",
|
||||
"uniqueFontIdentifier": "DeckCraft Legacy Regular",
|
||||
"fullName": "DeckCraft Legacy",
|
||||
"psName": "DeckCraftLegacy-Regular",
|
||||
}, 400)
|
||||
@@ -6,7 +6,9 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod morph;
|
||||
pub mod preset;
|
||||
pub mod warp;
|
||||
|
||||
pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
//! Morph between two outlines: one shape turns into another (a circle into a star).
|
||||
//!
|
||||
//! The approach is manim's: both outlines get the same number of contours and each contour pair the
|
||||
//! same number of cubic curves, then every control point moves in a straight line. On top of that,
|
||||
//! closed contours are turned and reversed so that their points travel as short a way as possible
|
||||
//! (a triangle does not twist on its way into a circle), and extra points go to the longest curves.
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point, Rect, Vec2};
|
||||
|
||||
use crate::preset::{FillMode, Geometry, SubPath};
|
||||
|
||||
/// A contour as cubic curves: the start anchor, then three points per curve (handle, handle,
|
||||
/// anchor).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Contour {
|
||||
pub closed: bool,
|
||||
pub pts: Vec<Point>,
|
||||
}
|
||||
|
||||
impl Contour {
|
||||
pub fn curves(&self) -> usize {
|
||||
self.pts.len().saturating_sub(1) / 3
|
||||
}
|
||||
}
|
||||
|
||||
fn line_to(p: &mut Vec<Point>, to: Point) {
|
||||
let Some(&from) = p.last() else { return };
|
||||
p.extend([from.lerp(to, 1.0 / 3.0), from.lerp(to, 2.0 / 3.0), to]);
|
||||
}
|
||||
|
||||
fn close(c: &mut Contour) {
|
||||
if let (Some(&first), Some(&last)) = (c.pts.first(), c.pts.last())
|
||||
&& first != last
|
||||
{
|
||||
line_to(&mut c.pts, first);
|
||||
}
|
||||
c.closed = true;
|
||||
}
|
||||
|
||||
fn pen(cur: &mut Option<Contour>, start: Point) -> &mut Contour {
|
||||
cur.get_or_insert_with(|| Contour { closed: false, pts: vec![start] })
|
||||
}
|
||||
|
||||
fn keep(c: Contour, out: &mut Vec<Contour>) {
|
||||
if c.curves() > 0 && c.pts.iter().all(|p| p.is_finite()) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// The contours of a path, every segment as a cubic curve. Contours without a curve or with
|
||||
/// non-finite points are left out.
|
||||
pub fn contours(path: &BezPath) -> Vec<Contour> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Contour> = None;
|
||||
let mut start = Point::ZERO;
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if let Some(c) = cur.take() {
|
||||
keep(c, &mut out);
|
||||
}
|
||||
start = p;
|
||||
cur = Some(Contour { closed: false, pts: vec![p] });
|
||||
}
|
||||
PathEl::LineTo(p) => line_to(&mut pen(&mut cur, start).pts, p),
|
||||
PathEl::QuadTo(q, p) => {
|
||||
let c = pen(&mut cur, start);
|
||||
if let Some(&a) = c.pts.last() {
|
||||
c.pts.extend([a.lerp(q, 2.0 / 3.0), p.lerp(q, 2.0 / 3.0), p]);
|
||||
}
|
||||
}
|
||||
PathEl::CurveTo(a, b, p) => pen(&mut cur, start).pts.extend([a, b, p]),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(mut c) = cur.take()
|
||||
&& c.curves() > 0
|
||||
{
|
||||
close(&mut c);
|
||||
keep(c, &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(c) = cur {
|
||||
keep(c, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Contours back to a path.
|
||||
pub fn to_path(cs: &[Contour]) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for c in cs {
|
||||
let Some(&s) = c.pts.first() else { continue };
|
||||
bp.move_to(s);
|
||||
for [a, b, p] in c.pts.get(1..).unwrap_or(&[]).as_chunks::<3>().0 {
|
||||
bp.curve_to(*a, *b, *p);
|
||||
}
|
||||
if c.closed {
|
||||
bp.close_path();
|
||||
}
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
// --- alignment ---------------------------------------------------------------------------------
|
||||
|
||||
/// Rough curve length: mean of chord and control polygon.
|
||||
fn curve_length(c: &[Point]) -> f64 {
|
||||
let [p0, p1, p2, p3] = c else { return 0.0 };
|
||||
(p0.distance(*p3) + p0.distance(*p1) + p1.distance(*p2) + p2.distance(*p3)) / 2.0
|
||||
}
|
||||
|
||||
/// Appends curve `c` (four points) split into `n` pieces of equal parameter length (de Casteljau),
|
||||
/// without its start anchor.
|
||||
fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
|
||||
let [a, b, d, e] = c else { return };
|
||||
let (mut p0, mut p1, mut p2, p3) = (*a, *b, *d, *e);
|
||||
for j in (2..=n).rev() {
|
||||
let t = 1.0 / j as f64;
|
||||
let (ab, bc, cd) = (p0.lerp(p1, t), p1.lerp(p2, t), p2.lerp(p3, t));
|
||||
let (abc, bcd) = (ab.lerp(bc, t), bc.lerp(cd, t));
|
||||
let m = abc.lerp(bcd, t);
|
||||
out.extend([ab, abc, m]);
|
||||
(p0, p1, p2) = (m, bcd, cd);
|
||||
}
|
||||
out.extend([p1, p2, p3]);
|
||||
}
|
||||
|
||||
/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
|
||||
/// splits go to the longest pieces, so a long side gets more points than a short one.
|
||||
fn refine(c: &Contour, n: usize) -> Contour {
|
||||
let have = c.curves();
|
||||
if have >= n || have == 0 {
|
||||
return c.clone();
|
||||
}
|
||||
let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect();
|
||||
let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
|
||||
let mut parts = vec![1usize; curves.len()];
|
||||
for _ in 0..n - have {
|
||||
let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64;
|
||||
let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
|
||||
if let Some(p) = parts.get_mut(best) {
|
||||
*p += 1;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(1 + 3 * n);
|
||||
out.extend(c.pts.first().copied());
|
||||
for (q, p) in curves.iter().zip(&parts) {
|
||||
split_curve(q, *p, &mut out);
|
||||
}
|
||||
Contour { closed: c.closed, pts: out }
|
||||
}
|
||||
|
||||
const MIN_CURVES: usize = 12;
|
||||
const MAX_CURVES: usize = 60;
|
||||
/// Start points tried per closed contour; longer contours are sampled evenly.
|
||||
const MAX_TRIES: usize = 128;
|
||||
|
||||
/// How many curves a contour pair gets: a common multiple of both counts while that stays small
|
||||
/// (then a triangle meets a circle at matching places and the morph stays symmetric), and at least
|
||||
/// [`MIN_CURVES`] for a smooth middle.
|
||||
fn curves_for(na: usize, nb: usize) -> usize {
|
||||
fn gcd(x: usize, y: usize) -> usize {
|
||||
if y == 0 { x } else { gcd(y, x % y) }
|
||||
}
|
||||
let hi = na.max(nb).max(1);
|
||||
let mut n = if na > 0 && nb > 0 { (na / gcd(na, nb)).saturating_mul(nb) } else { hi };
|
||||
if n > MAX_CURVES {
|
||||
n = hi;
|
||||
}
|
||||
n.saturating_mul(MIN_CURVES.div_ceil(n).max(1))
|
||||
}
|
||||
|
||||
/// Box around all points, handles included.
|
||||
fn bounds<'a>(pts: impl IntoIterator<Item = &'a Point>) -> Option<Rect> {
|
||||
pts.into_iter().fold(None, |r: Option<Rect>, p| Some(r.map_or(Rect::from_points(*p, *p), |r| r.union_pt(*p))))
|
||||
}
|
||||
|
||||
fn center(pts: &[Point]) -> Point {
|
||||
bounds(pts).map(|r| r.center()).unwrap_or(Point::ZERO)
|
||||
}
|
||||
|
||||
/// The same contour, starting at anchor `s`, optionally run backwards.
|
||||
fn turn(pts: &[Point], s: usize, back: bool) -> Vec<Point> {
|
||||
let q: Vec<Point> = if back { pts.iter().rev().copied().collect() } else { pts.to_vec() };
|
||||
if s == 0 {
|
||||
return q;
|
||||
}
|
||||
let body = q.get(1..).unwrap_or(&[]);
|
||||
let cut = (3 * s).min(body.len());
|
||||
let mut out = Vec::with_capacity(q.len());
|
||||
out.extend(cut.checked_sub(1).and_then(|i| body.get(i)).copied());
|
||||
out.extend_from_slice(body.get(cut..).unwrap_or(&[]));
|
||||
out.extend_from_slice(body.get(..cut).unwrap_or(&[]));
|
||||
out
|
||||
}
|
||||
|
||||
/// One contour pair of equal curve count: `b` runs backwards if `back` is set, then the closed side
|
||||
/// (`b` if it is closed, else `a`) gets the start that lets the points travel least. Both sides are
|
||||
/// compared around their own centre, so where the shapes are does not matter.
|
||||
fn match_pair(a: &Contour, b: &Contour, back: bool) -> (Contour, Contour, f64) {
|
||||
let bp = if back { turn(&b.pts, 0, true) } else { b.pts.clone() };
|
||||
let turn_a = !b.closed && a.closed;
|
||||
let (fixed, free) = if turn_a { (&bp, &a.pts) } else { (&a.pts, &bp) };
|
||||
let (fc, gc) = (center(fixed), center(free));
|
||||
let tries = if turn_a || b.closed { free.len().saturating_sub(1) / 3 } else { 1 };
|
||||
let mut best = free.clone();
|
||||
let mut best_cost = f64::INFINITY;
|
||||
for s in (0..tries.max(1)).step_by(tries.div_ceil(MAX_TRIES).max(1)) {
|
||||
let q = turn(free, s, false);
|
||||
let mut cost = 0.0;
|
||||
for (p, f) in q.iter().zip(fixed.iter()) {
|
||||
cost += ((*p - gc) - (*f - fc)).hypot2();
|
||||
if cost >= best_cost {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if cost < best_cost {
|
||||
best_cost = cost;
|
||||
best = q;
|
||||
}
|
||||
}
|
||||
let (pa, pb) = if turn_a { (best, bp) } else { (a.pts.clone(), best) };
|
||||
(Contour { closed: a.closed, pts: pa }, Contour { closed: b.closed, pts: pb }, best_cost)
|
||||
}
|
||||
|
||||
/// Where a contour without partner shrinks to (or grows from): the spot at the same place relative
|
||||
/// to the other outline's box, or its own centre without one.
|
||||
fn place(c: &Contour, from: Option<Rect>, to: Option<Rect>) -> Point {
|
||||
let p = center(&c.pts);
|
||||
let (Some(from), Some(to)) = (from, to) else { return p };
|
||||
let fx = if from.width() > 0.0 { (p.x - from.x0) / from.width() } else { 0.5 };
|
||||
let fy = if from.height() > 0.0 { (p.y - from.y0) / from.height() } else { 0.5 };
|
||||
Point::new(to.x0 + to.width() * fx, to.y0 + to.height() * fy)
|
||||
}
|
||||
|
||||
/// Two contour lists brought to the same number of contours and the same number of curves per
|
||||
/// contour pair. Contours pair up in order; one without partner meets a point (see [`place`];
|
||||
/// `ba`/`bb` are the boxes of the whole outlines). Whether `b` runs backwards is decided once for all
|
||||
/// its contours: turning single ones around would change which areas a nonzero fill covers.
|
||||
pub fn align(a: &[Contour], b: &[Contour], ba: Option<Rect>, bb: Option<Rect>) -> (Vec<Contour>, Vec<Contour>) {
|
||||
// (a, b, either side a point).
|
||||
let mut pairs: Vec<(Contour, Contour, bool)> = Vec::new();
|
||||
for k in 0..a.len().max(b.len()) {
|
||||
let (ca, cb) = (a.get(k), b.get(k));
|
||||
let n = curves_for(ca.map_or(0, Contour::curves), cb.map_or(0, Contour::curves));
|
||||
let point = |c: &Contour, from, to, closed| Contour { closed, pts: vec![place(c, from, to); 1 + 3 * n] };
|
||||
match (ca, cb) {
|
||||
(Some(x), Some(y)) => pairs.push((refine(x, n), refine(y, n), false)),
|
||||
(Some(x), None) => pairs.push((refine(x, n), point(x, ba, bb, x.closed), true)),
|
||||
(None, Some(y)) => pairs.push((point(y, bb, ba, y.closed), refine(y, n), true)),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
let mut best: Option<(Vec<Contour>, Vec<Contour>, f64)> = None;
|
||||
for back in [false, true] {
|
||||
let (mut xs, mut ys, mut cost) = (Vec::new(), Vec::new(), 0.0);
|
||||
for (x, y, point) in &pairs {
|
||||
if *point {
|
||||
xs.push(x.clone());
|
||||
ys.push(if back { Contour { closed: y.closed, pts: turn(&y.pts, 0, true) } } else { y.clone() });
|
||||
} else {
|
||||
let (mx, my, c) = match_pair(x, y, back);
|
||||
xs.push(mx);
|
||||
ys.push(my);
|
||||
cost += c;
|
||||
}
|
||||
}
|
||||
if best.as_ref().is_none_or(|b| cost < b.2) {
|
||||
best = Some((xs, ys, cost));
|
||||
}
|
||||
}
|
||||
best.map(|(x, y, _)| (x, y)).unwrap_or_default()
|
||||
}
|
||||
|
||||
// --- whole geometries ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Look {
|
||||
fill: FillMode,
|
||||
stroke: bool,
|
||||
even_odd: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Part {
|
||||
a: Option<Look>,
|
||||
b: Option<Look>,
|
||||
ca: Vec<Contour>,
|
||||
cb: Vec<Contour>,
|
||||
}
|
||||
|
||||
/// Two shape geometries brought to the same structure once, then blended per frame with
|
||||
/// [`GeometryMorph::at`]. Sub-paths pair up in order; one without partner grows out of (or shrinks
|
||||
/// into) a point. When a pair differs in fill mode, outline or fill rule, the two looks cross-fade
|
||||
/// (see [`Fade`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeometryMorph {
|
||||
parts: Vec<Part>,
|
||||
text: (Rect, Rect),
|
||||
}
|
||||
|
||||
fn look(s: &SubPath) -> Look {
|
||||
Look { fill: s.fill, stroke: s.stroke, even_odd: s.even_odd }
|
||||
}
|
||||
|
||||
/// How strongly a sub-path of a morphed geometry shows: its fill and its outline, 0..1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Fade {
|
||||
pub fill: f64,
|
||||
pub stroke: f64,
|
||||
}
|
||||
|
||||
/// The layers a pair of looks is drawn with at `t`, bottom first. A fill or an outline only one side
|
||||
/// has fades out (or in). When both fill, the side with the even-odd rule lies opaque underneath and
|
||||
/// the other fades over it, so a donut's hole fills smoothly; with the same rule both cover the same
|
||||
/// area, so this is a plain cross-fade (shading of 3-D faces).
|
||||
fn layers(a: Option<Look>, b: Option<Look>, t: f64) -> Vec<(Look, Fade)> {
|
||||
let (a, b) = match (a, b) {
|
||||
(Some(a), Some(b)) if a != b => (a, b),
|
||||
(Some(x), _) | (None, Some(x)) => return vec![(x, Fade { fill: 1.0, stroke: 1.0 })],
|
||||
(None, None) => return vec![],
|
||||
};
|
||||
let side = |on_a: bool, on_b: bool| match (on_a, on_b) {
|
||||
(true, true) => 1.0,
|
||||
(true, false) => 1.0 - t,
|
||||
(false, true) => t,
|
||||
(false, false) => 0.0,
|
||||
};
|
||||
let (stroke, line) = (a.stroke || b.stroke, side(a.stroke, b.stroke));
|
||||
let (fa, fb) = (a.fill != FillMode::None, b.fill != FillMode::None);
|
||||
if fa && fb {
|
||||
let (under, over, alpha) = if a.even_odd || !b.even_odd { (a, b, t) } else { (b, a, 1.0 - t) };
|
||||
vec![(Look { stroke, ..under }, Fade { fill: 1.0, stroke: line }), (Look { stroke: false, ..over }, Fade { fill: alpha, stroke: 0.0 })]
|
||||
} else {
|
||||
let filled = if fa { a } else { b };
|
||||
vec![(Look { stroke, ..filled }, Fade { fill: side(fa, fb), stroke: line })]
|
||||
}
|
||||
}
|
||||
|
||||
impl GeometryMorph {
|
||||
/// `a` and `b` each in their own shape-local space.
|
||||
pub fn new(a: &Geometry, b: &Geometry) -> Self {
|
||||
let ca: Vec<Vec<Contour>> = a.paths.iter().map(|s| contours(&s.path)).collect();
|
||||
let cb: Vec<Vec<Contour>> = b.paths.iter().map(|s| contours(&s.path)).collect();
|
||||
let ba = bounds(ca.iter().flatten().flat_map(|c| &c.pts));
|
||||
let bb = bounds(cb.iter().flatten().flat_map(|c| &c.pts));
|
||||
let mut parts = Vec::new();
|
||||
for k in 0..a.paths.len().max(b.paths.len()) {
|
||||
let (xa, xb) = (ca.get(k).map(Vec::as_slice).unwrap_or(&[]), cb.get(k).map(Vec::as_slice).unwrap_or(&[]));
|
||||
let (pa, pb) = align(xa, xb, ba, bb);
|
||||
parts.push(Part { a: a.paths.get(k).map(look), b: b.paths.get(k).map(look), ca: pa, cb: pb });
|
||||
}
|
||||
GeometryMorph { parts, text: (a.text_rect, b.text_rect) }
|
||||
}
|
||||
|
||||
/// The geometry at `t` (0..1), with a [`Fade`] for each of its sub-paths. The points of `a` are
|
||||
/// first scaled by `sa` and those of `b` by `sb`, to bring both into the current box.
|
||||
pub fn at(&self, t: f64, sa: Vec2, sb: Vec2) -> (Geometry, Vec<Fade>) {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let scale = |p: Point, s: Vec2| Point::new(p.x * s.x, p.y * s.y);
|
||||
let (mut paths, mut fades) = (Vec::new(), Vec::new());
|
||||
for part in &self.parts {
|
||||
let cs: Vec<Contour> = part
|
||||
.ca
|
||||
.iter()
|
||||
.zip(&part.cb)
|
||||
.map(|(x, y)| Contour {
|
||||
closed: x.closed && y.closed,
|
||||
pts: x.pts.iter().zip(&y.pts).map(|(p, q)| scale(*p, sa).lerp(scale(*q, sb), t)).collect(),
|
||||
})
|
||||
.collect();
|
||||
let path = to_path(&cs);
|
||||
for (lk, fade) in layers(part.a, part.b, t) {
|
||||
paths.push(SubPath { path: path.clone(), fill: lk.fill, stroke: lk.stroke, even_odd: lk.even_odd });
|
||||
fades.push(fade);
|
||||
}
|
||||
}
|
||||
let r = |r: Rect, s: Vec2| Rect::new(r.x0 * s.x, r.y0 * s.y, r.x1 * s.x, r.y1 * s.y);
|
||||
let (ta, tb) = (r(self.text.0, sa), r(self.text.1, sb));
|
||||
let text_rect = Rect::from_points(ta.origin().lerp(tb.origin(), t), Point::new(ta.x1, ta.y1).lerp(Point::new(tb.x1, tb.y1), t));
|
||||
(Geometry { paths, text_rect, handles: vec![], sites: vec![] }, fades)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::preset::build;
|
||||
use kurbo::Shape as _;
|
||||
|
||||
fn bbox(g: &Geometry) -> Rect {
|
||||
g.outline().bounding_box()
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
(a.x0 - b.x0).abs() < 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && (a.x1 - b.x1).abs() < 1e-6 && (a.y1 - b.y1).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contours_turn_lines_and_quads_into_cubics() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((3.0, 0.0));
|
||||
p.quad_to((3.0, 3.0), (0.0, 3.0));
|
||||
p.close_path();
|
||||
// After a close, drawing goes on from the start point.
|
||||
p.line_to((5.0, 5.0));
|
||||
p.move_to((9.0, 9.0));
|
||||
let cs = contours(&p);
|
||||
assert_eq!(cs.len(), 2, "{cs:?}");
|
||||
assert!(cs[0].closed && !cs[1].closed);
|
||||
// line, quad and the closing line.
|
||||
assert_eq!(cs[0].curves(), 3);
|
||||
assert_eq!(cs[0].pts[1], Point::new(1.0, 0.0));
|
||||
assert_eq!(cs[0].pts.last(), cs[0].pts.first());
|
||||
let end = Point::new(5.0, 5.0);
|
||||
assert_eq!(cs[1].pts, vec![Point::ZERO, Point::ZERO.lerp(end, 1.0 / 3.0), Point::ZERO.lerp(end, 2.0 / 3.0), end]);
|
||||
// The lone move_to at the end makes no contour, and the path comes back the same.
|
||||
assert_eq!(contours(&to_path(&cs)), cs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_splits_the_longest_curves_and_keeps_the_shape() {
|
||||
let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] };
|
||||
let mut long = c.clone();
|
||||
line_to(&mut long.pts, Point::new(3.0, 30.0));
|
||||
let r = refine(&long, 5);
|
||||
assert_eq!(r.curves(), 5);
|
||||
// The short first side stays one curve, the long one gets the other four.
|
||||
assert_eq!(r.pts[3], Point::new(3.0, 0.0));
|
||||
assert_eq!(r.pts.last(), Some(&Point::new(3.0, 30.0)));
|
||||
assert!(r.pts.iter().all(|p| (p.x - 3.0).abs() < 1e-9 || p.y == 0.0));
|
||||
assert_eq!(refine(&c, 1), c);
|
||||
assert_eq!(curves_for(3, 4), 12);
|
||||
assert_eq!(curves_for(4, 4), 12);
|
||||
// The common multiple 77 is too many: the larger count, doubled to reach the minimum.
|
||||
assert_eq!(curves_for(7, 11), 22);
|
||||
assert_eq!(curves_for(0, 0), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_turns_into_a_star_and_lands_exactly() {
|
||||
let (a, b) = (build("ellipse", 100.0, 100.0, &[]).unwrap(), build("star5", 200.0, 100.0, &[]).unwrap());
|
||||
let m = GeometryMorph::new(&a, &b);
|
||||
let one = Vec2::new(1.0, 1.0);
|
||||
assert!(near(bbox(&m.at(0.0, one, one).0), bbox(&a)));
|
||||
assert!(near(bbox(&m.at(1.0, one, one).0), bbox(&b)));
|
||||
let mid = m.at(0.5, one, one).0;
|
||||
assert_eq!(mid.paths.len(), 1);
|
||||
assert!(mid.paths[0].path.elements().iter().all(|e| e.end_point().is_none_or(|p| p.is_finite())));
|
||||
// Non-finite progress is the start.
|
||||
assert!(near(bbox(&m.at(f64::NAN, one, one).0), bbox(&a)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_does_not_twist_into_a_circle() {
|
||||
let (a, b) = (build("triangle", 100.0, 100.0, &[]).unwrap(), build("ellipse", 100.0, 100.0, &[]).unwrap());
|
||||
let ca = contours(&a.paths[0].path);
|
||||
let cb = contours(&b.paths[0].path);
|
||||
let (x, y) = align(&ca, &cb, None, None);
|
||||
// Every point travels less than the shapes are wide: no point crosses to the other side.
|
||||
let far = x[0].pts.iter().zip(&y[0].pts).map(|(p, q)| p.distance(*q)).fold(0.0, f64::max);
|
||||
assert!(far < 60.0, "{far}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_sub_paths_grow_out_of_a_point() {
|
||||
let (a, b) = (build("rect", 100.0, 100.0, &[]).unwrap(), build("cube", 100.0, 100.0, &[]).unwrap());
|
||||
assert!(b.paths.len() > 1);
|
||||
let m = GeometryMorph::new(&a, &b);
|
||||
let one = Vec2::new(1.0, 1.0);
|
||||
let start = m.at(0.0, one, one).0;
|
||||
assert_eq!(start.paths.len(), b.paths.len());
|
||||
for sp in start.paths.iter().skip(1) {
|
||||
let r = sp.path.bounding_box();
|
||||
assert!(r.width() < 1e-9 && r.height() < 1e-9, "{r:?}");
|
||||
}
|
||||
// Looks switch at the midpoint: the cube's shaded faces show from there on.
|
||||
assert_eq!(m.at(1.0, one, one).0.paths.iter().map(|s| s.fill).collect::<Vec<_>>(), b.paths.iter().map(|s| s.fill).collect::<Vec<_>>());
|
||||
// And the other way round, the faces shrink away.
|
||||
let back = GeometryMorph::new(&b, &a).at(1.0, one, one).0;
|
||||
assert!(back.paths.iter().skip(1).all(|sp| sp.path.bounding_box().area() < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differing_looks_cross_fade() {
|
||||
let one = Vec2::new(1.0, 1.0);
|
||||
let fades = |a: &Geometry, b: &Geometry, t: f64| {
|
||||
let (g, f) = GeometryMorph::new(a, b).at(t, one, one);
|
||||
g.paths.iter().zip(f).map(|(s, f)| (s.fill, s.even_odd, s.stroke, f.fill, f.stroke)).collect::<Vec<_>>()
|
||||
};
|
||||
// A filled rectangle into a line: the fill fades out, the outline stays.
|
||||
let (r, line) = (build("rect", 100.0, 100.0, &[]).unwrap(), build("line", 100.0, 0.0, &[]).unwrap());
|
||||
assert_eq!(fades(&r, &line, 0.25), [(FillMode::Norm, false, true, 0.75, 1.0)]);
|
||||
assert_eq!(fades(&line, &r, 0.25), [(FillMode::Norm, false, true, 0.25, 1.0)]);
|
||||
// A donut into a circle: the donut (even-odd) lies underneath, the circle fades in over its hole.
|
||||
let (d, e) = (build("donut", 100.0, 100.0, &[]).unwrap(), build("ellipse", 100.0, 100.0, &[]).unwrap());
|
||||
assert!(d.paths[0].even_odd && !e.paths[0].even_odd);
|
||||
assert_eq!(fades(&d, &e, 0.25), [(FillMode::Norm, true, true, 1.0, 1.0), (FillMode::Norm, false, false, 0.25, 0.0)]);
|
||||
assert_eq!(fades(&e, &d, 0.25), [(FillMode::Norm, true, true, 1.0, 1.0), (FillMode::Norm, false, false, 0.75, 0.0)]);
|
||||
// Same looks: one layer, fully there.
|
||||
assert_eq!(fades(&e, &r, 0.5), [(FillMode::Norm, false, true, 1.0, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scales_bring_both_sides_into_the_current_box() {
|
||||
let (a, b) = (build("rect", 10.0, 10.0, &[]).unwrap(), build("ellipse", 40.0, 20.0, &[]).unwrap());
|
||||
let m = GeometryMorph::new(&a, &b);
|
||||
// Current box 20 x 20: a scaled up twice, b to half the width.
|
||||
let g = m.at(0.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0;
|
||||
assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0)));
|
||||
assert!(near(g.text_rect, Rect::new(a.text_rect.x0 * 2.0, a.text_rect.y0 * 2.0, a.text_rect.x1 * 2.0, a.text_rect.y1 * 2.0)));
|
||||
let g = m.at(1.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0;
|
||||
assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_degenerate_geometries_do_not_crash() {
|
||||
let empty = Geometry { paths: vec![], text_rect: Rect::ZERO, handles: vec![], sites: vec![] };
|
||||
let r = build("rect", 10.0, 10.0, &[]).unwrap();
|
||||
let line = build("line", 10.0, 0.0, &[]).unwrap();
|
||||
let mut nan = BezPath::new();
|
||||
nan.move_to((f64::NAN, 0.0));
|
||||
nan.line_to((1.0, 1.0));
|
||||
let bad = Geometry { paths: vec![SubPath { path: nan, fill: FillMode::Norm, stroke: true, even_odd: false }], ..empty.clone() };
|
||||
for (x, y) in [(&empty, &empty), (&empty, &r), (&r, &empty), (&r, &line), (&line, &r), (&bad, &r), (&r, &bad)] {
|
||||
for t in [0.0, 0.5, 1.0, f64::INFINITY] {
|
||||
let g = GeometryMorph::new(x, y).at(t, Vec2::new(1.0, 1.0), Vec2::new(0.0, 1e300)).0;
|
||||
// At most two layers per sub-path pair.
|
||||
assert!(g.paths.len() <= 2 * x.paths.len().max(y.paths.len()));
|
||||
}
|
||||
}
|
||||
// A rectangle into a line: the line is open, so the morph ends open.
|
||||
let g = GeometryMorph::new(&r, &line).at(1.0, Vec2::new(1.0, 1.0), Vec2::new(1.0, 1.0)).0;
|
||||
assert!(!g.paths[0].path.elements().iter().any(|e| matches!(e, PathEl::ClosePath)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_contours_sample_their_start_points() {
|
||||
// A polygon with many corners still aligns (and quickly): the start search is capped.
|
||||
let mut p = BezPath::new();
|
||||
for k in 0..2000 {
|
||||
let a = k as f64 / 2000.0 * std::f64::consts::TAU;
|
||||
let q = (50.0 + 50.0 * a.cos(), 50.0 + 50.0 * a.sin());
|
||||
if k == 0 { p.move_to(q) } else { p.line_to(q) }
|
||||
}
|
||||
p.close_path();
|
||||
let many = contours(&p);
|
||||
let e = contours(&build("ellipse", 100.0, 100.0, &[]).unwrap().paths[0].path);
|
||||
let (x, y) = align(&many, &e, None, None);
|
||||
assert_eq!(x[0].curves(), y[0].curves());
|
||||
assert_eq!(x[0].curves(), 2000);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod extra;
|
||||
|
||||
/// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -148,6 +150,7 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("bentConnector3", "Connector: Elbow", Lines, [50000]),
|
||||
p!("curvedConnector3", "Connector: Curved", Lines, [50000]),
|
||||
p!("bentConnector2", "Connector: Elbow (one bend)", Lines, []),
|
||||
p!("lineInv", "Line: Rising", Lines, []),
|
||||
p!("rect", "Rectangle", Rectangles, []),
|
||||
p!("roundRect", "Rectangle: Rounded Corners", Rectangles, [16667]),
|
||||
p!("snip1Rect", "Rectangle: Single Corner Snipped", Rectangles, [16667]),
|
||||
@@ -163,6 +166,7 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("rtTriangle", "Right Triangle", BasicShapes, []),
|
||||
p!("parallelogram", "Parallelogram", BasicShapes, [25000]),
|
||||
p!("trapezoid", "Trapezoid", BasicShapes, [25000]),
|
||||
p!("nonIsoscelesTrapezoid", "Trapezoid: Uneven Sides", BasicShapes, [25000, 25000]),
|
||||
p!("diamond", "Diamond", BasicShapes, []),
|
||||
p!("pentagon", "Regular Pentagon", BasicShapes, []),
|
||||
p!("hexagon", "Hexagon", BasicShapes, [25000]),
|
||||
@@ -171,9 +175,13 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("decagon", "Decagon", BasicShapes, []),
|
||||
p!("dodecagon", "Dodecagon", BasicShapes, []),
|
||||
p!("pie", "Partial Circle", BasicShapes, [0, 16200000]),
|
||||
p!("pieWedge", "Quarter Wedge", BasicShapes, []),
|
||||
p!("chord", "Chord", BasicShapes, [2700000, 16200000]),
|
||||
p!("teardrop", "Teardrop", BasicShapes, [100000]),
|
||||
p!("frame", "Frame", BasicShapes, [12500]),
|
||||
p!("cornerTabs", "Corner Tabs: Triangles", BasicShapes, []),
|
||||
p!("squareTabs", "Corner Tabs: Squares", BasicShapes, []),
|
||||
p!("plaqueTabs", "Corner Tabs: Quarter Circles", BasicShapes, []),
|
||||
p!("halfFrame", "Half Frame", BasicShapes, [33333, 33333]),
|
||||
p!("corner", "L-Shape", BasicShapes, [50000, 50000]),
|
||||
p!("diagStripe", "Diagonal Stripe", BasicShapes, [50000]),
|
||||
@@ -192,6 +200,9 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("sun", "Sun", BasicShapes, [25000]),
|
||||
p!("moon", "Moon", BasicShapes, [50000]),
|
||||
p!("cloud", "Cloud", BasicShapes, []),
|
||||
p!("gear6", "Gear: 6 Teeth", BasicShapes, [15000, 3526]),
|
||||
p!("gear9", "Gear: 9 Teeth", BasicShapes, [10000, 1763]),
|
||||
p!("funnel", "Funnel", BasicShapes, []),
|
||||
p!("arc", "Arc", BasicShapes, [16200000, 0]),
|
||||
p!("bracketPair", "Double Bracket", BasicShapes, [16667]),
|
||||
p!("bracePair", "Double Brace", BasicShapes, [8333]),
|
||||
@@ -199,6 +210,9 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("rightBracket", "Right Bracket", BasicShapes, [8333]),
|
||||
p!("leftBrace", "Left Brace", BasicShapes, [8333, 50000]),
|
||||
p!("rightBrace", "Right Brace", BasicShapes, [8333, 50000]),
|
||||
p!("chartX", "Chart Marker: Cross Lines", BasicShapes, []),
|
||||
p!("chartStar", "Chart Marker: Star Lines", BasicShapes, []),
|
||||
p!("chartPlus", "Chart Marker: Plus Lines", BasicShapes, []),
|
||||
p!("rightArrow", "Arrow: Right", BlockArrows, [50000, 50000]),
|
||||
p!("leftArrow", "Arrow: Left", BlockArrows, [50000, 50000]),
|
||||
p!("upArrow", "Arrow: Up", BlockArrows, [50000, 50000]),
|
||||
@@ -209,16 +223,27 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("leftRightUpArrow", "Arrow: Left-Right-Up", BlockArrows, [25000, 25000, 25000]),
|
||||
p!("bentArrow", "Arrow: Bent", BlockArrows, [25000, 25000, 25000, 43750]),
|
||||
p!("uturnArrow", "Arrow: U-Turn", BlockArrows, [25000, 25000, 25000, 43750, 75000]),
|
||||
p!("curvedRightArrow", "Arrow: Curved Right", BlockArrows, [25000, 50000, 25000]),
|
||||
p!("curvedLeftArrow", "Arrow: Curved Left", BlockArrows, [25000, 50000, 25000]),
|
||||
p!("curvedUpArrow", "Arrow: Curved Up", BlockArrows, [25000, 50000, 25000]),
|
||||
p!("curvedDownArrow", "Arrow: Curved Down", BlockArrows, [25000, 50000, 25000]),
|
||||
p!("leftUpArrow", "Arrow: Left-Up", BlockArrows, [25000, 25000, 25000]),
|
||||
p!("bentUpArrow", "Arrow: Bent-Up", BlockArrows, [25000, 25000, 25000]),
|
||||
p!("stripedRightArrow", "Arrow: Striped Right", BlockArrows, [50000, 50000]),
|
||||
p!("notchedRightArrow", "Arrow: Notched Right", BlockArrows, [50000, 50000]),
|
||||
p!("homePlate", "Arrow: Pentagon", BlockArrows, [50000]),
|
||||
p!("chevron", "Arrow: Chevron", BlockArrows, [50000]),
|
||||
p!("circularArrow", "Arrow: Circular", BlockArrows, [12500, 1142319, 20457681, 10800000, 12500]),
|
||||
p!("leftCircularArrow", "Arrow: Circular Counter-clockwise", BlockArrows, [12500, -1142319, 1142319, 10800000, 12500]),
|
||||
p!("leftRightCircularArrow", "Arrow: Circular Two-headed", BlockArrows, [12500, 1142319, 20457681, 11942319, 12500]),
|
||||
p!("swooshArrow", "Arrow: Swoosh", BlockArrows, [25000, 16667]),
|
||||
p!("rightArrowCallout", "Callout: Right Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
|
||||
p!("downArrowCallout", "Callout: Down Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
|
||||
p!("leftArrowCallout", "Callout: Left Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
|
||||
p!("upArrowCallout", "Callout: Up Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
|
||||
p!("leftRightArrowCallout", "Callout: Left-Right Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
|
||||
p!("upDownArrowCallout", "Callout: Up-Down Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
|
||||
p!("quadArrowCallout", "Callout: Quad Arrow", BlockArrows, [18515, 18515, 18515, 48123]),
|
||||
p!("mathPlus", "Plus Sign", EquationShapes, [23520]),
|
||||
p!("mathMinus", "Minus Sign", EquationShapes, [23520]),
|
||||
p!("mathMultiply", "Multiplication Sign", EquationShapes, [23520]),
|
||||
@@ -253,6 +278,7 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("flowChartMagneticDisk", "Flowchart: Magnetic Disk", Flowchart, []),
|
||||
p!("flowChartMagneticDrum", "Flowchart: Direct Access Storage", Flowchart, []),
|
||||
p!("flowChartDisplay", "Flowchart: Display", Flowchart, []),
|
||||
p!("flowChartOfflineStorage", "Flowchart: Offline Storage", Flowchart, []),
|
||||
p!("irregularSeal1", "Explosion: 8 Points", StarsAndBanners, []),
|
||||
p!("irregularSeal2", "Explosion: 14 Points", StarsAndBanners, []),
|
||||
p!("star4", "Star: 4 Points", StarsAndBanners, [12500]),
|
||||
@@ -267,6 +293,9 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("star32", "Star: 32 Points", StarsAndBanners, [37500]),
|
||||
p!("ribbon2", "Ribbon: Tilted Up", StarsAndBanners, [16667, 50000]),
|
||||
p!("ribbon", "Ribbon: Tilted Down", StarsAndBanners, [16667, 50000]),
|
||||
p!("ellipseRibbon2", "Ribbon: Curved and Tilted Up", StarsAndBanners, [25000, 50000, 12500]),
|
||||
p!("ellipseRibbon", "Ribbon: Curved and Tilted Down", StarsAndBanners, [25000, 50000, 12500]),
|
||||
p!("leftRightRibbon", "Ribbon: Left-Right Fold", StarsAndBanners, [50000, 50000, 16667]),
|
||||
p!("verticalScroll", "Scroll: Vertical", StarsAndBanners, [12500]),
|
||||
p!("horizontalScroll", "Scroll: Horizontal", StarsAndBanners, [12500]),
|
||||
p!("wave", "Wave", StarsAndBanners, [12500, 0]),
|
||||
@@ -276,6 +305,22 @@ pub static CATALOG: &[PresetInfo] = &[
|
||||
p!("wedgeEllipseCallout", "Speech Bubble: Oval", Callouts, [-20833, 62500]),
|
||||
p!("cloudCallout", "Thought Bubble: Cloud", Callouts, [-20833, 62500]),
|
||||
p!("borderCallout1", "Callout: Line", Callouts, [18750, -8333, 112500, -38333]),
|
||||
p!("borderCallout2", "Callout: Bent Line", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
|
||||
p!("borderCallout3", "Callout: Double Bent Line", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
|
||||
p!("accentCallout1", "Callout: Line with Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
|
||||
p!("accentCallout2", "Callout: Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
|
||||
p!("accentCallout3", "Callout: Double Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
|
||||
p!("callout1", "Callout: Line with No Border", Callouts, [18750, -8333, 112500, -38333]),
|
||||
p!("callout2", "Callout: Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
|
||||
p!("callout3", "Callout: Double Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
|
||||
p!("accentBorderCallout1", "Callout: Line with Border and Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
|
||||
p!("accentBorderCallout2", "Callout: Bent Line with Border and Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
|
||||
p!(
|
||||
"accentBorderCallout3",
|
||||
"Callout: Double Bent Line with Border and Accent Bar",
|
||||
Callouts,
|
||||
[18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]
|
||||
),
|
||||
p!("actionButtonBackPrevious", "Action Button: Go Back or Previous", ActionButtons, []),
|
||||
p!("actionButtonForwardNext", "Action Button: Go Forward or Next", ActionButtons, []),
|
||||
p!("actionButtonBeginning", "Action Button: Go to Beginning", ActionButtons, []),
|
||||
@@ -1535,7 +1580,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take();
|
||||
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] }
|
||||
}
|
||||
_ => return None,
|
||||
_ => return extra::build(name, w, h, &a),
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
//! More preset outlines: line callouts, arrow callouts, curved and circular arrows, curved
|
||||
//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own
|
||||
//! construction, fitted to how the shapes look and to what their adjust values mean.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Builds the presets this module knows; `None` for any other name.
|
||||
pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option<Geometry> {
|
||||
let ss = w.min(h);
|
||||
let full = Rect::new(0.0, 0.0, w, h);
|
||||
let mut b = Pb::new();
|
||||
let g = match name {
|
||||
"lineInv" => {
|
||||
let l = b.m(0.0, h).l(w, 0.0).take();
|
||||
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::new(0.0, h), Point::new(w, 0.0)] }
|
||||
}
|
||||
"nonIsoscelesTrapezoid" => {
|
||||
let max = 50000.0 * w / ss.max(1e-9);
|
||||
let (v1, v2) = (a(0).clamp(0.0, max), a(1).clamp(0.0, max));
|
||||
let (d1, d2) = (ss * v1 / 1e5, ss * v2 / 1e5);
|
||||
let mut g = simple(b.poly(&[(0.0, h), (d1, 0.0), (w - d2, 0.0), (w, h)]).take(), Rect::new(d1 * 0.66, h * 0.2, w - d2 * 0.66, h), w, h);
|
||||
g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 1e5, 0.0), v1, 0.0, max));
|
||||
g.handles.push(Handle::new(1, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v2, 0.0, max));
|
||||
g
|
||||
}
|
||||
"pieWedge" => {
|
||||
b.m(0.0, h).arc(w, h, 180.0, 90.0).l(w, h).z();
|
||||
simple(b.take(), Rect::new(w * 0.29289, h * 0.29289, w, h), w, h)
|
||||
}
|
||||
"flowChartOfflineStorage" => {
|
||||
let tri = b.poly(&[(0.0, 0.0), (w, 0.0), (w / 2.0, h)]).take();
|
||||
let bar = Pb::new().m(w * 0.4, h * 0.8).l(w * 0.6, h * 0.8).take();
|
||||
Geometry {
|
||||
paths: vec![fill(tri), stroke_only(bar)],
|
||||
text_rect: Rect::new(w / 4.0, 0.0, w * 0.75, h * 0.8),
|
||||
handles: vec![],
|
||||
sites: vec![Point::new(w / 2.0, 0.0), Point::new(w / 4.0, h / 2.0), Point::new(w / 2.0, h), Point::new(w * 0.75, h / 2.0)],
|
||||
}
|
||||
}
|
||||
"chartX" | "chartStar" | "chartPlus" => {
|
||||
let body = corner_rect(w, h, [Corner::Square; 4]);
|
||||
let mut l = Pb::new();
|
||||
if name != "chartPlus" {
|
||||
l.m(0.0, 0.0).l(w, h).m(0.0, h).l(w, 0.0);
|
||||
}
|
||||
if name != "chartX" {
|
||||
l.m(w / 2.0, 0.0).l(w / 2.0, h);
|
||||
}
|
||||
if name == "chartPlus" {
|
||||
l.m(0.0, h / 2.0).l(w, h / 2.0);
|
||||
}
|
||||
Geometry { paths: vec![fill_only(body), stroke_only(l.take())], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
"cornerTabs" | "squareTabs" | "plaqueTabs" => {
|
||||
// Each tab's size is a twentieth of the diagonal.
|
||||
let d = (w.hypot(h) / 20.0).min(w / 2.0).min(h / 2.0);
|
||||
match name {
|
||||
"cornerTabs" => {
|
||||
b.poly(&[(0.0, 0.0), (d, 0.0), (0.0, d)]);
|
||||
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d)]);
|
||||
b.poly(&[(w, h - d), (w, h), (w - d, h)]);
|
||||
b.poly(&[(0.0, h - d), (d, h), (0.0, h)]);
|
||||
}
|
||||
"squareTabs" => {
|
||||
b.poly(&[(0.0, 0.0), (d, 0.0), (d, d), (0.0, d)]);
|
||||
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d), (w - d, d)]);
|
||||
b.poly(&[(w - d, h - d), (w, h - d), (w, h), (w - d, h)]);
|
||||
b.poly(&[(0.0, h - d), (d, h - d), (d, h), (0.0, h)]);
|
||||
}
|
||||
_ => {
|
||||
if d > 0.0 {
|
||||
b.m(0.0, 0.0).l(d, 0.0).arc(d, d, 0.0, 90.0).z();
|
||||
b.m(w, 0.0).l(w, d).arc(d, d, 90.0, 90.0).z();
|
||||
b.m(w, h).l(w - d, h).arc(d, d, 180.0, 90.0).z();
|
||||
b.m(0.0, h).l(0.0, h - d).arc(d, d, 270.0, 90.0).z();
|
||||
} else {
|
||||
b.m(0.0, 0.0).z();
|
||||
}
|
||||
}
|
||||
}
|
||||
Geometry { paths: vec![fill(b.take())], text_rect: full.inset(-d), handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
"callout1"
|
||||
| "callout2"
|
||||
| "callout3"
|
||||
| "accentCallout1"
|
||||
| "accentCallout2"
|
||||
| "accentCallout3"
|
||||
| "borderCallout2"
|
||||
| "borderCallout3"
|
||||
| "accentBorderCallout1"
|
||||
| "accentBorderCallout2"
|
||||
| "accentBorderCallout3" => {
|
||||
let n = match name.chars().last() {
|
||||
Some('2') => 2,
|
||||
Some('3') => 3,
|
||||
_ => 1,
|
||||
};
|
||||
line_callout(w, h, a, n + 1, name.starts_with("accent"), name.contains("order"))
|
||||
}
|
||||
"leftRightArrowCallout" | "upDownArrowCallout" => {
|
||||
let vertical = name == "upDownArrowCallout";
|
||||
let (l, t) = if vertical { (h, w) } else { (w, h) };
|
||||
let ss = l.min(t);
|
||||
let head = (ss * a(1).max(0.0) / 1e5).min(t / 2.0);
|
||||
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
|
||||
let hl = (ss * a(2).max(0.0) / 1e5).min(l / 2.0);
|
||||
let boxl = (l * a(3).max(0.0) / 1e5).min(l - 2.0 * hl);
|
||||
let (c, b0, b1) = (t / 2.0, (l - boxl) / 2.0, (l + boxl) / 2.0);
|
||||
let pts = [
|
||||
(0.0, c),
|
||||
(hl, c - head),
|
||||
(hl, c - shaft / 2.0),
|
||||
(b0, c - shaft / 2.0),
|
||||
(b0, 0.0),
|
||||
(b1, 0.0),
|
||||
(b1, c - shaft / 2.0),
|
||||
(l - hl, c - shaft / 2.0),
|
||||
(l - hl, c - head),
|
||||
(l, c),
|
||||
(l - hl, c + head),
|
||||
(l - hl, c + shaft / 2.0),
|
||||
(b1, c + shaft / 2.0),
|
||||
(b1, t),
|
||||
(b0, t),
|
||||
(b0, c + shaft / 2.0),
|
||||
(hl, c + shaft / 2.0),
|
||||
(hl, c + head),
|
||||
];
|
||||
let d = if vertical { Dir::Down } else { Dir::Right };
|
||||
let pts = orient(d, w, h, &pts);
|
||||
let tr = orient(d, w, h, &[(b0, 0.0), (b1, t)]);
|
||||
simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h)
|
||||
}
|
||||
"quadArrowCallout" => {
|
||||
let head = (ss * a(1).max(0.0) / 1e5).min(ss / 2.0);
|
||||
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
|
||||
let hl = (ss * a(2).max(0.0) / 1e5).min(ss / 2.0);
|
||||
let k = a(3).clamp(0.0, 100000.0) / 1e5;
|
||||
let (bw, bh) = ((w * k).min(w - 2.0 * hl).max(shaft), (h * k).min(h - 2.0 * hl).max(shaft));
|
||||
let (cx, cy) = (w / 2.0, h / 2.0);
|
||||
let (bx0, bx1, by0, by1) = (cx - bw / 2.0, cx + bw / 2.0, cy - bh / 2.0, cy + bh / 2.0);
|
||||
let (s2, hd) = (shaft / 2.0, head);
|
||||
let pts = [
|
||||
(0.0, cy),
|
||||
(hl, cy - hd),
|
||||
(hl, cy - s2),
|
||||
(bx0, cy - s2),
|
||||
(bx0, by0),
|
||||
(cx - s2, by0),
|
||||
(cx - s2, hl),
|
||||
(cx - hd, hl),
|
||||
(cx, 0.0),
|
||||
(cx + hd, hl),
|
||||
(cx + s2, hl),
|
||||
(cx + s2, by0),
|
||||
(bx1, by0),
|
||||
(bx1, cy - s2),
|
||||
(w - hl, cy - s2),
|
||||
(w - hl, cy - hd),
|
||||
(w, cy),
|
||||
(w - hl, cy + hd),
|
||||
(w - hl, cy + s2),
|
||||
(bx1, cy + s2),
|
||||
(bx1, by1),
|
||||
(cx + s2, by1),
|
||||
(cx + s2, h - hl),
|
||||
(cx + hd, h - hl),
|
||||
(cx, h),
|
||||
(cx - hd, h - hl),
|
||||
(cx - s2, h - hl),
|
||||
(cx - s2, by1),
|
||||
(bx0, by1),
|
||||
(bx0, cy + s2),
|
||||
(hl, cy + s2),
|
||||
(hl, cy + hd),
|
||||
];
|
||||
simple(b.poly(&pts).take(), Rect::new(bx0, by0, bx1, by1), w, h)
|
||||
}
|
||||
"curvedRightArrow" | "curvedLeftArrow" | "curvedUpArrow" | "curvedDownArrow" => curved_arrow(name, w, h, a(0), a(1), a(2)),
|
||||
"swooshArrow" => swoosh(w, h, a(0), a(1)),
|
||||
"ellipseRibbon" | "ellipseRibbon2" => ellipse_ribbon(name == "ellipseRibbon2", w, h, a(0), a(1), a(2)),
|
||||
"leftRightRibbon" => left_right_ribbon(w, h, a(0), a(1), a(2)),
|
||||
"gear6" | "gear9" => gear(if name == "gear6" { 6 } else { 9 }, w, h, a(0), a(1)),
|
||||
"funnel" => funnel(w, h),
|
||||
"circularArrow" | "leftCircularArrow" | "leftRightCircularArrow" => circular_arrow(name, w, h, [a(0), a(1), a(2), a(3), a(4)]),
|
||||
_ => return None,
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// An ellipse-centred frame for the circular arrows: the band's middle runs on the ellipse
|
||||
/// `(rx, ry)`, its edges `th/2` either side.
|
||||
struct Ring {
|
||||
c: Point,
|
||||
rx: f64,
|
||||
ry: f64,
|
||||
th: f64,
|
||||
hw: f64,
|
||||
}
|
||||
|
||||
impl Ring {
|
||||
/// The point at visual angle `deg` on the ellipse grown by `d`.
|
||||
fn at(&self, deg: f64, d: f64) -> Point {
|
||||
let (ax, ay) = ((self.rx + d).max(0.0), (self.ry + d).max(0.0));
|
||||
let t = vis(deg, ax, ay).to_radians();
|
||||
Point::new(self.c.x + ax * t.cos(), self.c.y + ay * t.sin())
|
||||
}
|
||||
/// Parametric angle (degrees) of `p` on the ellipse grown by `d`.
|
||||
fn param(&self, p: Point, d: f64) -> f64 {
|
||||
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
|
||||
((p.y - self.c.y) / ay).atan2((p.x - self.c.x) / ax).to_degrees()
|
||||
}
|
||||
/// Where the line `b + s·u` meets the ellipse grown by `d`, taking the crossing nearest `b`
|
||||
/// on the side `sign` (+1 outwards, −1 inwards).
|
||||
fn cross(&self, b: Point, u: Vec2, d: f64, sign: f64) -> Point {
|
||||
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
|
||||
let (px, py) = ((b.x - self.c.x) / ax, (b.y - self.c.y) / ay);
|
||||
let (ux, uy) = (u.x / ax, u.y / ay);
|
||||
let qa = ux * ux + uy * uy;
|
||||
let qb = 2.0 * (px * ux + py * uy);
|
||||
let qc = px * px + py * py - 1.0;
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if qa < 1e-18 || disc < 0.0 {
|
||||
// The line misses (a long head on a thin ring): the edge then carries on from the
|
||||
// base centre itself, as the reference does.
|
||||
return b;
|
||||
}
|
||||
let r = disc.sqrt();
|
||||
let (s0, s1) = ((-qb - r) / (2.0 * qa), (-qb + r) / (2.0 * qa));
|
||||
let s = if sign > 0.0 {
|
||||
if s0 >= 0.0 { s0 } else { s1 }
|
||||
} else if s1 <= 0.0 {
|
||||
s1
|
||||
} else {
|
||||
s0
|
||||
};
|
||||
b + u * s
|
||||
}
|
||||
/// An arrow head whose base centre sits on the middle ellipse at `base` degrees and whose tip
|
||||
/// sits there at `base + sweep`. The base runs along the line from the centre to the tip.
|
||||
/// Returns (band outer edge, outer wing, tip, inner wing, band inner edge).
|
||||
fn head(&self, base: f64, sweep: f64) -> [Point; 5] {
|
||||
let b = self.at(base, 0.0);
|
||||
let tip = self.at(base + sweep, 0.0);
|
||||
let d = tip - self.c;
|
||||
let u = if d.hypot() > 1e-12 { d / d.hypot() } else { Vec2::new(1.0, 0.0) };
|
||||
let half = self.th / 2.0;
|
||||
[self.cross(b, u, half, 1.0), b + u * self.hw, tip, b - u * self.hw, self.cross(b, u, -half, -1.0)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep (degrees) from `a` to `b` turning one way: positive when `dir > 0`.
|
||||
fn turn(a: f64, b: f64, dir: f64) -> f64 {
|
||||
if dir > 0.0 { (b - a).rem_euclid(360.0) } else { -(a - b).rem_euclid(360.0) }
|
||||
}
|
||||
|
||||
/// The circular arrows: a band along an ellipse with one head (`circularArrow` clockwise,
|
||||
/// `leftCircularArrow` anticlockwise) or a head at each end (`leftRightCircularArrow`).
|
||||
/// Adjusts: band thickness, head sweep (degrees), end angle, start angle, head half-width.
|
||||
fn circular_arrow(name: &str, w: f64, h: f64, a: [f64; 5]) -> Geometry {
|
||||
let ss = w.min(h);
|
||||
if ss <= 0.0 {
|
||||
return simple(corner_rect(w, h, [Corner::Square; 4]), Rect::new(0.0, 0.0, w, h), w, h);
|
||||
}
|
||||
let a5 = a[4].clamp(0.0, 25000.0);
|
||||
let hw = ss * a5 / 1e5;
|
||||
let th = ss * a[0].clamp(0.0, 2.0 * a5) / 1e5;
|
||||
let ring = Ring { c: Point::new(w / 2.0, h / 2.0), rx: (w / 2.0 - hw).max(th / 2.0), ry: (h / 2.0 - hw).max(th / 2.0), th, hw };
|
||||
let deg = |v: f64| (v / 60000.0) % 360.0;
|
||||
let (sweep, end, start) = (deg(a[1]), deg(a[2]), deg(a[3]));
|
||||
let half = th / 2.0;
|
||||
let mut b = Pb::new();
|
||||
// Arcs of the ellipse grown by `d`, drawn on from the current point (which is on it except in
|
||||
// the degenerate case above, where the arc shifts with it like the reference's).
|
||||
let edge_arc = |b: &mut Pb, from: Point, to: Point, d: f64, dir: f64| {
|
||||
let (ax, ay) = ((ring.rx + d).max(0.0), (ring.ry + d).max(0.0));
|
||||
let (s, e) = (ring.param(from, d), ring.param(to, d));
|
||||
b.arc(ax, ay, s, turn(s, e, dir));
|
||||
};
|
||||
let outer_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, half, dir);
|
||||
let inner_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, -half, dir);
|
||||
if name == "leftRightCircularArrow" {
|
||||
let hs = ring.head(start, -sweep);
|
||||
let he = ring.head(end, sweep);
|
||||
b.m(hs[0].x, hs[0].y);
|
||||
outer_arc(&mut b, hs[0], he[0], 1.0);
|
||||
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
|
||||
inner_arc(&mut b, he[4], hs[4], -1.0);
|
||||
b.l(hs[3].x, hs[3].y).l(hs[2].x, hs[2].y).l(hs[1].x, hs[1].y).z();
|
||||
} else {
|
||||
let dir = if name == "circularArrow" { 1.0 } else { -1.0 };
|
||||
let he = ring.head(end, sweep);
|
||||
let (o0, i0) = (ring.at(start, half), ring.at(start, -half));
|
||||
b.m(o0.x, o0.y);
|
||||
outer_arc(&mut b, o0, he[0], dir);
|
||||
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
|
||||
inner_arc(&mut b, he[4], i0, -dir);
|
||||
b.z();
|
||||
}
|
||||
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
|
||||
}
|
||||
|
||||
/// A funnel: an elliptical rim (with an open mouth) across the top quarter, and straight sides
|
||||
/// down to a small rounded spout. The side lines leave the rim and meet the spout at fixed
|
||||
/// angles, fitted to the reference look.
|
||||
fn funnel(w: f64, h: f64) -> Geometry {
|
||||
const RIM: f64 = 173.0; // where the left side leaves the rim, degrees on the rim ellipse
|
||||
const SPOUT: f64 = 163.4; // where it meets the spout, degrees on the spout ellipse
|
||||
let ss = w.min(h);
|
||||
let (cx, rim_y) = (w / 2.0, h / 4.0);
|
||||
let (sx, sy) = (w / 8.0, h / 15.0);
|
||||
let spout_y = h - sy;
|
||||
let at = |rx: f64, ry: f64, cy: f64, deg: f64| {
|
||||
let t = deg.to_radians();
|
||||
Point::new(cx + rx * t.cos(), cy + ry * t.sin())
|
||||
};
|
||||
let p0 = at(cx, rim_y, rim_y, RIM);
|
||||
let p1 = at(sx, sy, spout_y, 180.0 - SPOUT);
|
||||
let mut b = Pb::new();
|
||||
b.m(p0.x, p0.y).arc(cx, rim_y, RIM, 540.0 - 2.0 * RIM);
|
||||
b.l(p1.x, p1.y).arc(sx, sy, 180.0 - SPOUT, 2.0 * SPOUT - 180.0).z();
|
||||
let d = ss / 20.0;
|
||||
b.ellipse(cx, rim_y, (cx - d).max(0.0), (rim_y - d).max(0.0));
|
||||
Geometry { paths: vec![fill_eo(b.take())], text_rect: Rect::new(w / 4.0, h / 2.0, w * 0.75, h - sy), handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
|
||||
/// A gear: an elliptical body with `n` teeth, the first pointing straight up. Each tooth leaves
|
||||
/// the body at two points an equal angle either side of its centre, and its tip corners sit a
|
||||
/// tooth height further out along the body's normal and a flank lean further in.
|
||||
fn gear(n: usize, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
|
||||
let ss = w.min(h);
|
||||
let th = ss * a0.clamp(0.0, 20000.0) / 1e5; // tooth height
|
||||
let lean_max = if n == 6 { 5380.0 } else { 2730.0 };
|
||||
let l = ss * a1.clamp(0.0, a0.clamp(0.0, 20000.0).min(lean_max)) / 1e5; // flank lean
|
||||
let (cx, cy) = (w / 2.0, h / 2.0);
|
||||
let (rx, ry) = ((cx - th).max(0.0), (cy - th).max(0.0));
|
||||
let r = (ss / 2.0 - th).max(1e-9);
|
||||
let half = ((th + l) / 2.0 / r).atan();
|
||||
let step = std::f64::consts::TAU / n as f64;
|
||||
let on_body = |t: f64| Point::new(cx + rx * t.cos(), cy + ry * t.sin());
|
||||
let mut b = Pb::new();
|
||||
for k in 0..n {
|
||||
let t = -std::f64::consts::FRAC_PI_2 + step * k as f64;
|
||||
// The tooth stands along the body's normal at its centre.
|
||||
let nrm = Vec2::new(t.cos() * ry, t.sin() * rx);
|
||||
let u = if nrm.hypot() > 1e-12 { nrm / nrm.hypot() } else { Vec2::new(t.cos(), t.sin()) };
|
||||
let v = Vec2::new(-u.y, u.x);
|
||||
let (r0, r1) = (on_body(t - half), on_body(t + half));
|
||||
let (c0, c1) = (r0 + u * th + v * l, r1 + u * th - v * l);
|
||||
if k == 0 {
|
||||
b.m(r0.x, r0.y);
|
||||
} else {
|
||||
b.arc(rx, ry, (t - step + half).to_degrees(), (step - 2.0 * half).to_degrees());
|
||||
}
|
||||
b.l(c0.x, c0.y).l(c1.x, c1.y).l(r1.x, r1.y);
|
||||
}
|
||||
let t_last = -std::f64::consts::FRAC_PI_2 + step * (n - 1) as f64;
|
||||
b.arc(rx, ry, (t_last + half).to_degrees(), (step - 2.0 * half).to_degrees()).z();
|
||||
let k = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let text = Rect::new(cx - rx * k, cy - ry * k, cx + rx * k, cy + ry * k);
|
||||
simple(b.take(), text, w, h)
|
||||
}
|
||||
|
||||
/// Two arrows on a band that steps down in the middle: the left arrow sits high, the right one low,
|
||||
/// and the band rolls over between them in a shaded half-ellipse curl.
|
||||
fn left_right_ribbon(w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
|
||||
let ss = w.min(h);
|
||||
let sh = h * a2.clamp(0.0, 33333.0) / 1e5; // step between the two halves
|
||||
let bt = (h * a0.max(0.0) / 1e5).min(h - sh); // band thickness
|
||||
let cx = w / 2.0;
|
||||
let (rx, ry) = (w / 32.0, (sh / 4.0).min(w / 24.0));
|
||||
let hb = (ss * a1.max(0.0) / 1e5).min(cx - rx); // head length
|
||||
let yc = (h - sh) / 2.0;
|
||||
let (lt, lb) = (yc - bt / 2.0, yc + bt / 2.0);
|
||||
let (rt, rb) = (lt + sh, lb + sh);
|
||||
// The left half: filled as a closed outline, stroked only where it can show.
|
||||
let mut edge = Pb::new();
|
||||
edge.m(cx - rx, lb).l(hb, lb).l(hb, h - sh).l(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt);
|
||||
edge.arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -90.0);
|
||||
let edge = edge.take();
|
||||
let mut left = Pb::new();
|
||||
left.m(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt).arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -180.0);
|
||||
left.l(cx, lb.max(lt + 4.0 * ry)).l(hb, lb).l(hb, h - sh).z();
|
||||
let left = left.take();
|
||||
let mut curl = Pb::new();
|
||||
curl.m(cx + rx, lt + ry).l(cx + rx, rt).l(cx, lt + 4.0 * ry).arc(rx, ry, 90.0, 180.0).arc(rx, ry, 90.0, -90.0).z();
|
||||
let mut right = Pb::new();
|
||||
right.m(w, yc + sh).l(w - hb, h).l(w - hb, rb).l(cx, rb).arc(rx, ry, 90.0, 90.0).l(cx - rx, rt - ry).arc(rx, ry, 180.0, -90.0);
|
||||
right.l(w - hb, rt).l(w - hb, sh).z();
|
||||
Geometry {
|
||||
paths: vec![fill_only(left), stroke_only(edge), shade(curl.take(), FillMode::DarkenLess), fill(right.take())],
|
||||
text_rect: Rect::new(hb.min(cx - rx), rt, (w - hb).max(cx + rx), lb.max(rt)),
|
||||
handles: vec![],
|
||||
sites: vec![Point::new(0.0, yc), Point::new(cx, rt), Point::new(w, yc + sh), Point::new(cx, lb)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a piece of the parabola `y = y0 + d·(1 − ((x − w/2)/(w/2))²)` from the current point (at
|
||||
/// `xa`) to `xb`, as one quadratic curve.
|
||||
fn sag(b: &mut Pb, w: f64, d: f64, y0: f64, xa: f64, xb: f64) {
|
||||
let hw = (w / 2.0).max(1e-9);
|
||||
let f = |x: f64| y0 + d * (1.0 - ((x - hw) / hw).powi(2));
|
||||
let slope = -2.0 * d * (xa - hw) / (hw * hw);
|
||||
b.q((xa + xb) / 2.0, f(xa) + slope * (xb - xa) / 2.0, xb, f(xb));
|
||||
}
|
||||
|
||||
/// A ribbon whose edges sag along one parabola: a front band in the middle and two notched ends
|
||||
/// behind it, joined by shaded folds. Built sagging downwards; `up` mirrors it vertically.
|
||||
fn ellipse_ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
|
||||
let th = h * a0.clamp(0.0, 100000.0) / 1e5; // band drop at the centre
|
||||
// Sag of every edge: never more than the drop, never so little that the band would rise
|
||||
// above the ends by more than half their height.
|
||||
let d = (h * a2.max(0.0) / 1e5).max(th - (h - th) / 2.0).clamp(0.0, th);
|
||||
let bw = w * a1.clamp(25000.0, 75000.0) / 1e5;
|
||||
let (x0, x1) = ((w - bw) / 2.0, (w + bw) / 2.0);
|
||||
let ear = w / 8.0;
|
||||
let (xf, xg) = (x0 + ear, x1 - ear);
|
||||
let e = h - th; // band height
|
||||
let off = th - d; // band top below the ends' top
|
||||
let t = |x: f64| {
|
||||
let hw = (w / 2.0).max(1e-9);
|
||||
d * (1.0 - ((x - hw) / hw).powi(2))
|
||||
};
|
||||
let mut ends = Pb::new();
|
||||
ends.m(0.0, 0.0);
|
||||
sag(&mut ends, w, d, 0.0, 0.0, xf);
|
||||
ends.l(xf, t(xf) + off).l(x0, t(x0) + off).l(x0, t(x0) + e);
|
||||
sag(&mut ends, w, d, e, x0, 0.0);
|
||||
ends.l(ear, t(ear) + e / 2.0).z();
|
||||
ends.m(w, 0.0).l(w - ear, t(w - ear) + e / 2.0).l(w, e);
|
||||
sag(&mut ends, w, d, e, w, x1);
|
||||
ends.l(x1, t(x1) + off).l(xg, t(xg) + off).l(xg, t(xg));
|
||||
sag(&mut ends, w, d, 0.0, xg, w);
|
||||
ends.z();
|
||||
let mut folds = Pb::new();
|
||||
folds.poly(&[(x0, t(x0) + off), (xf, t(xf)), (xf, t(xf) + off)]);
|
||||
folds.poly(&[(x1, t(x1) + off), (xg, t(xg)), (xg, t(xg) + off)]);
|
||||
let mut band = Pb::new();
|
||||
band.m(x0, t(x0) + off);
|
||||
sag(&mut band, w, d, off, x0, x1);
|
||||
band.l(x1, t(x1) + off + e);
|
||||
sag(&mut band, w, d, off + e, x1, x0);
|
||||
band.z();
|
||||
let mut paths = vec![fill(ends.take()), shade(folds.take(), FillMode::DarkenLess), fill(band.take())];
|
||||
let mut text = Rect::new(x0, th, x1, t(x0) + off + e);
|
||||
if up {
|
||||
let m = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, h]);
|
||||
for p in &mut paths {
|
||||
p.path.apply_affine(m);
|
||||
}
|
||||
text = m.transform_rect_bbox(text);
|
||||
}
|
||||
Geometry { paths, text_rect: text, handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
|
||||
/// A sweeping arrow rising from the bottom-left corner. The band's two edges are quadratic curves
|
||||
/// from the corner; the head's base leans slightly forward and reaches one eighth of the shorter
|
||||
/// side above and below the band. The tilt and the tip's lift were fitted to the reference look.
|
||||
fn swoosh(w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
|
||||
const TILT: f64 = 0.1126; // tan of the head base's lean from vertical
|
||||
const LIFT: f64 = 0.051; // tip height above the band's centre, per unit of height
|
||||
let ss = w.min(h);
|
||||
let e = ss / 8.0;
|
||||
let th = (h * a0.max(0.0) / 1e5).min((h - 2.0 * e).max(0.0));
|
||||
// The head is at least long enough to keep its leaning base inside the frame.
|
||||
let hl = (ss * a1.max(0.0) / 1e5).max((e + th) * TILT).min((w - e * TILT).max(0.0));
|
||||
let qx = w - hl;
|
||||
let mut b = Pb::new();
|
||||
b.m(0.0, h)
|
||||
.q(w / 6.0, h / 3.0, qx, e)
|
||||
.l(qx - e * TILT, 0.0)
|
||||
.l(w, (e + th / 2.0 - LIFT * h).clamp(0.0, 2.0 * e + th))
|
||||
.l((qx + (e + th) * TILT).min(w), 2.0 * e + th)
|
||||
.l((qx + th * TILT).min(w), e + th)
|
||||
.q(w / 4.0, (th + h / 12.0 + e).min(h), 0.0, h)
|
||||
.z();
|
||||
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
|
||||
}
|
||||
|
||||
/// A band that sweeps around half an ellipse and ends in an arrow head. Built pointing right
|
||||
/// (`curvedRightArrow`) in a `cw`×`ch` frame, then turned for the other directions.
|
||||
///
|
||||
/// Both halves of the band hang off one ellipse touching the far side: its centre sits on the
|
||||
/// head side's edge, its horizontal radius is the full length, and the band has a constant
|
||||
/// vertical thickness. The back half (shaded) starts flat against the head side; the front half
|
||||
/// runs to the head, centred on the band where it ends.
|
||||
fn curved_arrow(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
|
||||
let along_x = matches!(name, "curvedRightArrow" | "curvedLeftArrow");
|
||||
let (cw, ch) = if along_x { (w, h) } else { (h, w) };
|
||||
let ss = cw.min(ch);
|
||||
let head = (ss * a1.max(0.0) / 1e5).min(ch / 2.0);
|
||||
let th = (ss * a0.max(0.0) / 1e5).min(head);
|
||||
let hl = (ss * a2.max(0.0) / 1e5).min(cw);
|
||||
let hh = head / 2.0;
|
||||
// The band's centre line, carried on to the head side, ends at the tip, which sits half a
|
||||
// head width above the bottom.
|
||||
let r = ((ch - hh - th / 2.0) / 2.0).max(0.0);
|
||||
// Parametric angle where the front band reaches the head base.
|
||||
let te = if cw > 0.0 { (-(hl / cw)).clamp(-1.0, 1.0).acos().to_degrees() } else { 90.0 };
|
||||
let mut back = Pb::new();
|
||||
back.m(cw, 0.0).arc(cw, r, 270.0, -90.0).l(0.0, r + th).arc(cw, r, 180.0, 90.0).z();
|
||||
let mut front = Pb::new();
|
||||
front.m(0.0, r).arc(cw, r, 180.0, te - 180.0);
|
||||
let yb = front.cur.y;
|
||||
let yc = yb + th / 2.0;
|
||||
front.l(cw - hl, yc - hh).l(cw, 2.0 * r + th / 2.0).l(cw - hl, yc + hh).l(cw - hl, yb + th).arc(cw, r, te, 180.0 - te).z();
|
||||
let m = match name {
|
||||
"curvedLeftArrow" => kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]),
|
||||
"curvedUpArrow" => kurbo::Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]),
|
||||
"curvedDownArrow" => kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]),
|
||||
_ => kurbo::Affine::IDENTITY,
|
||||
};
|
||||
let (mut back, mut front) = (back.take(), front.take());
|
||||
back.apply_affine(m);
|
||||
front.apply_affine(m);
|
||||
Geometry {
|
||||
paths: vec![shade(back, FillMode::DarkenLess), fill(front)],
|
||||
text_rect: Rect::new(0.0, 0.0, w, h),
|
||||
handles: vec![],
|
||||
sites: mid_sites(w, h),
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle with a callout leader through `pts` points (`(y, x)` adjust pairs in 1/100 000 of
|
||||
/// the height and width), optionally with an accent bar at the first point's x and a border.
|
||||
fn line_callout(w: f64, h: f64, a: &dyn Fn(usize) -> f64, pts: usize, accent: bool, border: bool) -> Geometry {
|
||||
let body = corner_rect(w, h, [Corner::Square; 4]);
|
||||
// Leaders may reach well outside the box, but not without bound.
|
||||
let pt = |i: usize| (w * a(2 * i + 1).clamp(-1e7, 1e7) / 1e5, h * a(2 * i).clamp(-1e7, 1e7) / 1e5);
|
||||
let mut l = Pb::new();
|
||||
let (x0, y0) = pt(0);
|
||||
l.m(x0, y0);
|
||||
for i in 1..pts {
|
||||
let (x, y) = pt(i);
|
||||
l.l(x, y);
|
||||
}
|
||||
let mut paths = vec![if border { fill(body) } else { fill_only(body) }];
|
||||
if accent {
|
||||
paths.push(stroke_only(Pb::new().m(x0, 0.0).l(x0, h).take()));
|
||||
}
|
||||
paths.push(stroke_only(l.take()));
|
||||
Geometry { paths, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{CATALOG, FillMode, build, info};
|
||||
use kurbo::{BezPath, PathEl, Point, Shape};
|
||||
|
||||
const NEW: [&str; 38] = [
|
||||
"lineInv",
|
||||
"nonIsoscelesTrapezoid",
|
||||
"pieWedge",
|
||||
"leftRightArrowCallout",
|
||||
"upDownArrowCallout",
|
||||
"quadArrowCallout",
|
||||
"circularArrow",
|
||||
"leftCircularArrow",
|
||||
"leftRightCircularArrow",
|
||||
"curvedRightArrow",
|
||||
"curvedLeftArrow",
|
||||
"curvedUpArrow",
|
||||
"curvedDownArrow",
|
||||
"swooshArrow",
|
||||
"callout1",
|
||||
"callout2",
|
||||
"callout3",
|
||||
"accentCallout1",
|
||||
"accentCallout2",
|
||||
"accentCallout3",
|
||||
"borderCallout2",
|
||||
"borderCallout3",
|
||||
"accentBorderCallout1",
|
||||
"accentBorderCallout2",
|
||||
"accentBorderCallout3",
|
||||
"ellipseRibbon",
|
||||
"ellipseRibbon2",
|
||||
"leftRightRibbon",
|
||||
"flowChartOfflineStorage",
|
||||
"gear6",
|
||||
"gear9",
|
||||
"funnel",
|
||||
"cornerTabs",
|
||||
"squareTabs",
|
||||
"plaqueTabs",
|
||||
"chartX",
|
||||
"chartStar",
|
||||
"chartPlus",
|
||||
];
|
||||
|
||||
fn outline(name: &str, w: f64, h: f64, adj: &[f64]) -> BezPath {
|
||||
build(name, w, h, adj).map(|g| g.outline()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest point of the (flattened) outline.
|
||||
fn dist(path: &BezPath, p: Point) -> f64 {
|
||||
let mut best = f64::MAX;
|
||||
let mut prev = Point::ZERO;
|
||||
let mut start = Point::ZERO;
|
||||
kurbo::flatten(path.iter(), 0.01, |el| {
|
||||
let (a, b) = match el {
|
||||
PathEl::MoveTo(q) => {
|
||||
prev = q;
|
||||
start = q;
|
||||
return;
|
||||
}
|
||||
PathEl::LineTo(q) => (prev, q),
|
||||
PathEl::ClosePath => (prev, start),
|
||||
_ => return,
|
||||
};
|
||||
let d = b - a;
|
||||
let t = if d.hypot2() > 0.0 { ((p - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
best = best.min((a + d * t - p).hypot());
|
||||
prev = b;
|
||||
});
|
||||
best
|
||||
}
|
||||
|
||||
/// Asserts every point lies within 1pt of the outline.
|
||||
fn on(name: &str, w: f64, h: f64, adj: &[f64], pts: &[(f64, f64)]) {
|
||||
let path = outline(name, w, h, adj);
|
||||
for &(x, y) in pts {
|
||||
let d = dist(&path, Point::new(x, y));
|
||||
assert!(d < 1.0, "{name} {w}x{h} {adj:?}: ({x}, {y}) is {d:.2} from the outline");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_presets_are_in_the_catalog() {
|
||||
for n in NEW {
|
||||
let i = info(n).unwrap_or_else(|| panic!("{n} missing from the catalog"));
|
||||
assert!(!i.label.is_empty());
|
||||
}
|
||||
assert!(CATALOG.len() >= 183);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_presets_build_within_bounds_at_many_sizes_and_adjusts() {
|
||||
let sizes = [(480.0, 300.0), (300.0, 300.0), (200.0, 460.0), (100.0, 460.0), (1.0, 1.0), (0.0, 0.0), (0.0, 50.0), (60.0, 0.0)];
|
||||
let adjusts: [&[f64]; 8] = [
|
||||
&[],
|
||||
&[0.0; 8],
|
||||
&[100000.0; 8],
|
||||
&[-50000.0; 8],
|
||||
&[1e12, -1e12, 1e12, -1e12, 1e12, -1e12, 1e12, -1e12],
|
||||
&[f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN],
|
||||
&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 5.0, 6.0, 7.0, 8.0],
|
||||
&[30000.0, 60000.0, 20000.0, 9000000.0, 20000.0, 40000.0, 120000.0, -30000.0],
|
||||
];
|
||||
for n in NEW {
|
||||
// Line callouts' leaders may reach anywhere, by design.
|
||||
let callout = n.contains("allout") && !n.contains("Arrow");
|
||||
for (w, h) in sizes {
|
||||
for adj in adjusts {
|
||||
let g = build(n, w, h, adj).unwrap_or_else(|| panic!("{n} did not build"));
|
||||
assert!(!g.paths.is_empty(), "{n} has no paths");
|
||||
for s in &g.paths {
|
||||
let bb = s.path.bounding_box();
|
||||
assert!(bb.x0.is_finite() && bb.y0.is_finite() && bb.x1.is_finite() && bb.y1.is_finite(), "{n} {w}x{h} {adj:?}: {bb:?}");
|
||||
if !callout {
|
||||
// Circular arrows with a head longer than their ring can hold carry the
|
||||
// inner edge on from the head, as the reference does, so may spill.
|
||||
let spill = if n.contains("ircular") { 0.5 } else { 0.01 };
|
||||
let tol = 0.5 + w.max(h) * spill;
|
||||
assert!(
|
||||
bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol,
|
||||
"{n} {w}x{h} {adj:?} out of bounds: {bb:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let t = g.text_rect;
|
||||
assert!(t.x0.is_finite() && t.y0.is_finite() && t.x1.is_finite() && t.y1.is_finite(), "{n} text rect {t:?}");
|
||||
}
|
||||
}
|
||||
let g = build(n, 480.0, 300.0, &[]).unwrap_or_else(|| panic!("{n}"));
|
||||
assert!(g.outline().elements().len() >= 2, "{n} default outline is empty");
|
||||
}
|
||||
}
|
||||
|
||||
// The spot checks below are outline points measured on reference renders.
|
||||
|
||||
#[test]
|
||||
fn simple_new_presets_match_reference_points() {
|
||||
on("lineInv", 480.0, 300.0, &[], &[(0.0, 300.0), (480.0, 0.0)]);
|
||||
on("nonIsoscelesTrapezoid", 480.0, 300.0, &[], &[(75.0, 0.0), (405.0, 0.0), (0.0, 300.0)]);
|
||||
on("nonIsoscelesTrapezoid", 200.0, 460.0, &[80000.0, 20000.0], &[(100.0, 0.0), (160.0, 0.0)]);
|
||||
on("pieWedge", 480.0, 300.0, &[], &[(64.3, 150.0), (0.0, 300.0), (480.0, 0.0)]);
|
||||
on("flowChartOfflineStorage", 480.0, 300.0, &[], &[(240.0, 300.0), (192.0, 240.0), (288.0, 240.0)]);
|
||||
on("squareTabs", 480.0, 300.0, &[], &[(28.3, 28.3), (451.7, 271.7)]);
|
||||
on("cornerTabs", 480.0, 300.0, &[], &[(23.3, 5.0)]);
|
||||
on("plaqueTabs", 480.0, 300.0, &[], &[(27.85, 5.0)]);
|
||||
on("chartStar", 480.0, 300.0, &[], &[(240.0, 0.0), (240.0, 300.0), (48.0, 30.0)]);
|
||||
on("chartPlus", 480.0, 300.0, &[], &[(0.0, 150.0), (240.0, 0.0)]);
|
||||
// Chart markers and plain callouts leave the box unstroked.
|
||||
for n in ["chartX", "callout1", "accentCallout2"] {
|
||||
let g = build(n, 100.0, 100.0, &[]).unwrap_or_else(|| panic!("{n}"));
|
||||
assert!(!g.paths[0].stroke, "{n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_callouts_follow_their_adjusts() {
|
||||
on("callout3", 480.0, 300.0, &[], &[(-40.0, 56.25), (-80.0, 56.25), (-80.0, 300.0), (-40.0, 338.9)]);
|
||||
let adj = [50000.0, 110000.0, 20000.0, 120000.0, -20000.0, 80000.0, 30000.0, 40000.0];
|
||||
let g = build("accentBorderCallout3", 480.0, 300.0, &adj).unwrap_or_else(|| panic!("accentBorderCallout3"));
|
||||
assert_eq!(g.paths.len(), 3);
|
||||
assert!(g.paths[0].stroke);
|
||||
let accent = g.paths[1].path.bounding_box();
|
||||
assert!((accent.x0 - 528.0).abs() < 1e-6 && accent.y0 == 0.0 && accent.y1 == 300.0, "{accent:?}");
|
||||
on("accentBorderCallout3", 480.0, 300.0, &adj, &[(576.0, 60.0), (384.0, -60.0), (192.0, 90.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_callouts_match_reference_points() {
|
||||
on("leftRightArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (75.0, 75.0), (124.5, 0.0), (355.5, 300.0), (480.0, 150.0)]);
|
||||
on("leftRightArrowCallout", 200.0, 460.0, &[], &[(50.0, 180.0), (52.0, 0.0), (148.0, 460.0)]);
|
||||
on("upDownArrowCallout", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 77.8), (480.0, 222.2), (240.0, 300.0)]);
|
||||
on("quadArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (124.5, 77.8), (240.0, 0.0), (480.0, 150.0), (240.0, 300.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_arrows_match_reference_points() {
|
||||
on(
|
||||
"curvedRightArrow",
|
||||
480.0,
|
||||
300.0,
|
||||
&[],
|
||||
&[(480.0, 0.0), (480.0, 75.0), (480.0, 225.0), (405.0, 149.0), (405.0, 299.0), (100.0, 151.5), (300.0, 6.7)],
|
||||
);
|
||||
on("curvedRightArrow", 200.0, 460.0, &[30000.0, 60000.0, 40000.0], &[(200.0, 400.0), (120.0, 324.5), (120.0, 443.5), (200.0, 60.0)]);
|
||||
// The band never outgrows the head; the head never outgrows half the frame.
|
||||
on("curvedRightArrow", 200.0, 460.0, &[60000.0, 30000.0, 80000.0], &[(200.0, 60.0)]);
|
||||
on("curvedLeftArrow", 480.0, 300.0, &[], &[(0.0, 225.0), (0.0, 0.0)]);
|
||||
on("curvedUpArrow", 480.0, 300.0, &[], &[(405.0, 0.0), (0.0, 0.0), (75.0, 0.0)]);
|
||||
on("curvedDownArrow", 480.0, 300.0, &[], &[(405.0, 300.0), (0.0, 300.0)]);
|
||||
let g = build("curvedRightArrow", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("curvedRightArrow"));
|
||||
assert_eq!(g.paths[0].fill, FillMode::DarkenLess);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swoosh_matches_reference_points() {
|
||||
on("swooshArrow", 480.0, 300.0, &[], &[(0.0, 300.0), (425.8, 0.0), (480.0, 59.7), (442.8, 150.0), (202.0, 105.5), (202.0, 159.0)]);
|
||||
on("swooshArrow", 480.0, 300.0, &[40000.0, 16667.0], &[(480.0, 82.2), (447.8, 195.0)]);
|
||||
on("swooshArrow", 200.0, 460.0, &[], &[(163.9, 0.0), (200.0, 59.4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbons_match_reference_points() {
|
||||
on(
|
||||
"ellipseRibbon",
|
||||
480.0,
|
||||
300.0,
|
||||
&[],
|
||||
&[(0.0, 0.0), (120.0, 28.1), (60.0, 128.9), (240.0, 300.0), (120.0, 66.1), (180.0, 72.2), (180.0, 35.2)],
|
||||
);
|
||||
on("ellipseRibbon", 480.0, 300.0, &[30000.0, 60000.0, 20000.0], &[(120.0, 45.0), (240.0, 300.0), (96.0, 68.4)]);
|
||||
on("ellipseRibbon", 480.0, 300.0, &[80000.0, 20000.0, 60000.0], &[(240.0, 240.0), (1.0, 61.75)]);
|
||||
on("ellipseRibbon2", 480.0, 300.0, &[], &[(0.0, 300.0), (240.0, 0.0)]);
|
||||
on(
|
||||
"leftRightRibbon",
|
||||
480.0,
|
||||
300.0,
|
||||
&[],
|
||||
&[(0.0, 125.0), (150.0, 0.0), (150.0, 50.0), (255.0, 62.5), (225.0, 87.5), (480.0, 175.0), (330.0, 300.0)],
|
||||
);
|
||||
on("leftRightRibbon", 480.0, 300.0, &[90000.0, 10000.0, 60000.0], &[(0.0, 100.0), (480.0, 200.0), (240.0, 300.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gears_and_funnel_match_reference_points() {
|
||||
// Square gear6: body radius 105, top tooth tip 3.5 inside the frame, tip half-width ~16.3.
|
||||
on("gear6", 300.0, 300.0, &[], &[(150.0, 3.5), (133.7, 3.5), (166.3, 3.5), (45.0, 150.0)]);
|
||||
on("gear9", 300.0, 300.0, &[], &[(150.0, 1.0), (30.0, 150.0)]);
|
||||
on("gear6", 480.0, 300.0, &[], &[(240.0, 3.5), (201.0, 3.5)]);
|
||||
on("funnel", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 75.0), (240.0, 15.0), (15.0, 75.0), (240.0, 300.0), (60.5, 150.0)]);
|
||||
let g = build("funnel", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("funnel"));
|
||||
assert!(g.paths[0].even_odd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_arrows_match_reference_points() {
|
||||
on("circularArrow", 300.0, 300.0, &[], &[(19.0, 150.0), (56.5, 150.0), (150.0, 19.0), (262.4, 150.0), (218.6, 113.4), (293.6, 113.4)]);
|
||||
on(
|
||||
"circularArrow",
|
||||
300.0,
|
||||
300.0,
|
||||
&[20000.0, 1800000.0, 18000000.0, 5400000.0, 20000.0],
|
||||
&[(150.0, 270.0), (150.0, 210.0), (247.0, 42.1), (143.0, 102.1), (227.9, 105.0)],
|
||||
);
|
||||
on("leftCircularArrow", 300.0, 300.0, &[], &[(37.6, 150.0), (262.4, 150.0), (150.0, 281.0)]);
|
||||
on("leftRightCircularArrow", 300.0, 300.0, &[], &[(262.4, 150.0), (37.6, 150.0), (150.0, 19.0)]);
|
||||
// The band never grows past twice the head's half-width.
|
||||
on("circularArrow", 300.0, 300.0, &[25000.0, 1142319.0, 20457681.0, 10800000.0, 5000.0], &[(0.0, 150.0), (30.0, 150.0)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! WordArt text warps (DrawingML `a:bodyPr/a:prstTxWarp`).
|
||||
//!
|
||||
//! A warp preset is a list of bands laid over the shape's text rectangle (insets are ignored).
|
||||
//! Text lines are shared out over the bands in order (the first ⌈n/k⌉ lines to the first band
|
||||
//! and so on). A band either
|
||||
//! - stretches its lines between a top and a bottom guide ([`Band::Fill`]): the ink box of the
|
||||
//! band's lines fills the band vertically (a band whose ink is shorter than the tallest band's
|
||||
//! keeps the same scale, centred), and horizontally the text is scaled so that the
|
||||
//! widest band is filled along the mean length of its guides (one scale for the whole text), or
|
||||
//! - sets its lines at their natural size along one guide ([`Band::Follow`], arches and circles):
|
||||
//! glyphs keep their size, are placed by arc length (aligned as in the paragraph, the whole
|
||||
//! guide being the line's width: insets don't count) and stand on
|
||||
//! the guide's left normal; lines further from the guide stack outwards. Lines that are longer
|
||||
//! than the guide are shrunk to fit.
|
||||
//!
|
||||
//! Positions along a guide are by arc length; a fill band maps the same fraction of its top and
|
||||
//! bottom guide's length and interpolates between them. The guide curves were derived from
|
||||
//! observation: synthetic decks of `I` bars were drawn with PowerPoint and every curve fitted to
|
||||
//! the measured bar ends (most fit within a point on a 420 × 200 pt box).
|
||||
use kurbo::{Arc, BezPath, PathEl, Point, Rect, Vec2};
|
||||
|
||||
/// Flattening tolerance for guides, in points.
|
||||
const TOL: f64 = 0.02;
|
||||
|
||||
/// A guide curve flattened to a polyline with cumulative arc lengths.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Guide {
|
||||
pts: Vec<Point>,
|
||||
cum: Vec<f64>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
fn from_points(raw: impl IntoIterator<Item = Point>, closed: bool) -> Guide {
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
let mut cum: Vec<f64> = Vec::new();
|
||||
for p in raw {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
match pts.last() {
|
||||
Some(q) => {
|
||||
let d = (p - *q).hypot();
|
||||
if d > 1e-9 {
|
||||
cum.push(cum.last().copied().unwrap_or(0.0) + d);
|
||||
pts.push(p);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
pts.push(p);
|
||||
cum.push(0.0);
|
||||
}
|
||||
}
|
||||
if pts.len() > 100_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Guide { pts, cum, closed }
|
||||
}
|
||||
|
||||
/// A guide through `path` (any curves), flattened.
|
||||
pub fn from_path(path: &BezPath, closed: bool) -> Guide {
|
||||
let mut pts = Vec::new();
|
||||
kurbo::flatten(path.iter(), TOL, |el| match el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p),
|
||||
_ => {}
|
||||
});
|
||||
Guide::from_points(pts, closed)
|
||||
}
|
||||
|
||||
/// Total arc length.
|
||||
pub fn length(&self) -> f64 {
|
||||
self.cum.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Whether the guide is a closed loop (positions past its end wrap around).
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed
|
||||
}
|
||||
|
||||
/// Point and unit tangent at arc length `s`. Open guides continue along their end tangents
|
||||
/// past either end; closed guides wrap.
|
||||
pub fn at(&self, s: f64) -> (Point, Vec2) {
|
||||
let len = self.length();
|
||||
let (Some(&first), Some(&last)) = (self.pts.first(), self.pts.last()) else {
|
||||
return (Point::ZERO, Vec2::new(1.0, 0.0));
|
||||
};
|
||||
if self.pts.len() < 2 || len <= 1e-9 {
|
||||
return (first, Vec2::new(1.0, 0.0));
|
||||
}
|
||||
let s = if s.is_finite() { s } else { 0.0 };
|
||||
let s = if self.closed { s.rem_euclid(len) } else { s };
|
||||
let seg = |i: usize| -> (Point, Point, f64) {
|
||||
let a = self.pts.get(i).copied().unwrap_or(first);
|
||||
let b = self.pts.get(i + 1).copied().unwrap_or(last);
|
||||
(a, b, self.cum.get(i).copied().unwrap_or(0.0))
|
||||
};
|
||||
let n = self.pts.len();
|
||||
let i = if s <= 0.0 {
|
||||
0
|
||||
} else if s >= len {
|
||||
n - 2
|
||||
} else {
|
||||
self.cum.partition_point(|c| *c <= s).saturating_sub(1).min(n - 2)
|
||||
};
|
||||
let (a, b, c0) = seg(i);
|
||||
let d = b - a;
|
||||
let l = d.hypot();
|
||||
let t = if l > 1e-12 { d / l } else { Vec2::new(1.0, 0.0) };
|
||||
(a + t * (s - c0), t)
|
||||
}
|
||||
|
||||
/// The parallel curve `d` points away on the left of the direction of travel (above a
|
||||
/// guide running left to right).
|
||||
pub fn offset(&self, d: f64) -> Guide {
|
||||
let n = self.pts.len();
|
||||
if n < 2 || !d.is_finite() || d == 0.0 {
|
||||
return self.clone();
|
||||
}
|
||||
let unit = |i: usize| -> Vec2 {
|
||||
let (a, b) = (self.pts.get(i).copied().unwrap_or(Point::ZERO), self.pts.get(i + 1).copied().unwrap_or(Point::ZERO));
|
||||
let v = b - a;
|
||||
let l = v.hypot();
|
||||
if l > 1e-12 { v / l } else { Vec2::ZERO }
|
||||
};
|
||||
let pts = (0..n).map(|i| {
|
||||
let before = if i > 0 {
|
||||
unit(i - 1)
|
||||
} else if self.closed {
|
||||
unit(n - 2)
|
||||
} else {
|
||||
Vec2::ZERO
|
||||
};
|
||||
let after = if i + 1 < n {
|
||||
unit(i)
|
||||
} else if self.closed {
|
||||
unit(0)
|
||||
} else {
|
||||
Vec2::ZERO
|
||||
};
|
||||
let t = before + after;
|
||||
let l = t.hypot();
|
||||
let t = if l > 1e-12 { t / l } else { Vec2::new(1.0, 0.0) };
|
||||
self.pts.get(i).copied().unwrap_or(Point::ZERO) + Vec2::new(t.y, -t.x) * d
|
||||
});
|
||||
Guide::from_points(pts.collect::<Vec<_>>(), self.closed)
|
||||
}
|
||||
|
||||
/// The point at fraction `t` of the length.
|
||||
pub fn at_frac(&self, t: f64) -> Point {
|
||||
self.at(t * self.length()).0
|
||||
}
|
||||
}
|
||||
|
||||
/// Which line of a follow band sits on its guide.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LineAnchor {
|
||||
/// The last line; earlier lines stack outwards (arch up, circle).
|
||||
Last,
|
||||
/// The first line; later lines stack below it (arch down).
|
||||
First,
|
||||
/// The middle of the band's lines.
|
||||
Middle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Band {
|
||||
/// Text stretched between two guides.
|
||||
Fill { top: Guide, bottom: Guide },
|
||||
/// Text at its natural size along a guide.
|
||||
Follow { path: Guide, anchor: LineAnchor },
|
||||
}
|
||||
|
||||
impl Band {
|
||||
/// The length text is measured against: the mean of a fill band's guides.
|
||||
pub fn length(&self) -> f64 {
|
||||
match self {
|
||||
Band::Fill { top, bottom } => (top.length() + bottom.length()) / 2.0,
|
||||
Band::Follow { path, .. } => path.length(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved warp: its bands in shape-local points.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Warp {
|
||||
pub bands: Vec<Band>,
|
||||
}
|
||||
|
||||
/// Presets whose text follows a guide at its natural size (no wrapping).
|
||||
pub fn is_follow(name: &str) -> bool {
|
||||
matches!(name, "textArchUp" | "textArchDown" | "textCircle" | "textButton")
|
||||
}
|
||||
|
||||
/// Default adjust values of a warp preset (`adj`, or `adj1`, `adj2`); empty for unknown presets.
|
||||
pub fn adjust_defaults(name: &str) -> &'static [f64] {
|
||||
match name {
|
||||
"textPlain" => &[50_000.0],
|
||||
"textStop" => &[25_000.0],
|
||||
"textTriangle" | "textTriangleInverted" => &[50_000.0],
|
||||
"textChevron" => &[25_000.0],
|
||||
"textChevronInverted" => &[75_000.0],
|
||||
"textRingInside" | "textRingOutside" => &[60_000.0],
|
||||
"textArchUp" | "textCircle" | "textButton" => &[10_800_000.0],
|
||||
"textArchDown" => &[0.0],
|
||||
"textArchUpPour" | "textCirclePour" | "textButtonPour" => &[10_800_000.0, 50_000.0],
|
||||
"textArchDownPour" => &[0.0, 25_000.0],
|
||||
"textCurveUp" | "textCurveDown" => &[45_977.0],
|
||||
"textCanUp" => &[85_000.0],
|
||||
"textCanDown" => &[14_286.0],
|
||||
"textWave1" | "textWave2" => &[12_500.0, 0.0],
|
||||
"textDoubleWave1" | "textWave4" => &[6_250.0, 0.0],
|
||||
"textInflate" | "textDeflate" => &[18_750.0],
|
||||
"textInflateBottom" => &[60_000.0],
|
||||
"textDeflateBottom" | "textDeflateTop" => &[50_000.0],
|
||||
"textInflateTop" => &[40_000.0],
|
||||
"textDeflateInflate" => &[35_000.0],
|
||||
"textDeflateInflateDeflate" => &[25_000.0],
|
||||
"textFadeRight" | "textFadeLeft" | "textFadeUp" | "textFadeDown" => &[33_333.0],
|
||||
"textSlantUp" => &[55_556.0],
|
||||
"textSlantDown" => &[44_444.0],
|
||||
"textCascadeUp" | "textCascadeDown" => &[44_444.0],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of adjust values a warp preset takes.
|
||||
pub fn adjust_count(name: &str) -> usize {
|
||||
adjust_defaults(name).len()
|
||||
}
|
||||
|
||||
/// Whether `name` is a warp preset DeckCraft draws.
|
||||
pub fn is_supported(name: &str) -> bool {
|
||||
!adjust_defaults(name).is_empty()
|
||||
}
|
||||
|
||||
/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Seg {
|
||||
M(f64, f64),
|
||||
L(f64, f64),
|
||||
Q(f64, f64, f64, f64),
|
||||
C(f64, f64, f64, f64, f64, f64),
|
||||
/// Elliptical arc: centre, radii, start and sweep in degrees (y down: positive = clockwise).
|
||||
A(f64, f64, f64, f64, f64, f64),
|
||||
}
|
||||
|
||||
enum Spec {
|
||||
Fill(Vec<Seg>, Vec<Seg>),
|
||||
Follow(Vec<Seg>, LineAnchor),
|
||||
}
|
||||
|
||||
struct Adj<'a> {
|
||||
raw: &'a [f64],
|
||||
defaults: &'static [f64],
|
||||
}
|
||||
|
||||
impl Adj<'_> {
|
||||
fn raw(&self, i: usize) -> f64 {
|
||||
self.raw.get(i).copied().filter(|v| v.is_finite()).or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0)
|
||||
}
|
||||
/// Adjust `i` as a fraction (1/100 000), pinned to `lo..=hi` (fractions).
|
||||
fn frac(&self, i: usize, lo: f64, hi: f64) -> f64 {
|
||||
(self.raw(i) / 100_000.0).clamp(lo, hi)
|
||||
}
|
||||
/// Adjust `i` as an angle in degrees (1/60 000), in 0..360.
|
||||
fn angle(&self, i: usize) -> f64 {
|
||||
(self.raw(i) / 60_000.0).rem_euclid(360.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn line(pts: &[(f64, f64)]) -> Vec<Seg> {
|
||||
pts.iter().enumerate().map(|(i, &(x, y))| if i == 0 { Seg::M(x, y) } else { Seg::L(x, y) }).collect()
|
||||
}
|
||||
|
||||
/// Quadratic from (0, y0) through control (0.5, c) to (1, y0).
|
||||
fn quad(y0: f64, c: f64) -> Vec<Seg> {
|
||||
vec![Seg::M(0.0, y0), Seg::Q(0.5, c, 1.0, y0)]
|
||||
}
|
||||
|
||||
/// A sweep in (0, 360]: zero means a full turn.
|
||||
fn turn(deg: f64) -> f64 {
|
||||
let d = deg.rem_euclid(360.0);
|
||||
if d < 1e-9 { 360.0 } else { d }
|
||||
}
|
||||
|
||||
/// Arc of the ellipse inscribed in the rectangle, scaled by `s` about its centre.
|
||||
fn ellipse(s: f64, start: f64, sweep: f64) -> Vec<Seg> {
|
||||
vec![Seg::A(0.5, 0.5, 0.5 * s, 0.5 * s, start, sweep)]
|
||||
}
|
||||
|
||||
/// The arch-up arc for adjust angle `a`: symmetric about the top (270°), clockwise.
|
||||
fn arch_up(a: f64) -> (f64, f64) {
|
||||
(a, turn(2.0 * (270.0 - a)))
|
||||
}
|
||||
|
||||
/// The arch-down arc for adjust angle `a`: symmetric about the bottom (90°), counter-clockwise.
|
||||
fn arch_down(a: f64) -> (f64, f64) {
|
||||
(180.0 - a, -turn(180.0 - 2.0 * a))
|
||||
}
|
||||
|
||||
/// The circle arc for adjust angle `a`: from `a` clockwise, symmetric about 0°.
|
||||
fn circle(a: f64) -> (f64, f64) {
|
||||
(a, turn(2.0 * (360.0 - a)))
|
||||
}
|
||||
|
||||
/// One cubic wave period from x0 to x1 at height y: up first when `k` > 0 (y decreases).
|
||||
fn wave(out: &mut Vec<Seg>, x0: f64, x1: f64, y: f64, k: f64) {
|
||||
let l = x1 - x0;
|
||||
out.push(Seg::C(x0 + l / 3.0, y - k, x0 + 2.0 * l / 3.0, y + k, x1, y));
|
||||
}
|
||||
|
||||
/// Wave guides: `periods` cubic waves over the width, shifted apart by `shift` (fraction).
|
||||
fn waves(a: f64, shift: f64, periods: usize, up_first: bool) -> Spec {
|
||||
let d = 2.0 * shift;
|
||||
let (top_x, bot_x) = if d >= 0.0 { ((0.0, 1.0 - d), (d, 1.0)) } else { ((-d, 1.0), (0.0, 1.0 + d)) };
|
||||
let k = if up_first { a * 10.0 / 3.0 } else { -a * 10.0 / 3.0 };
|
||||
let guide = |(x0, x1): (f64, f64), y: f64| {
|
||||
let mut v = vec![Seg::M(x0, y)];
|
||||
let n = periods.max(1) as f64;
|
||||
for i in 0..periods.max(1) {
|
||||
let f = i as f64;
|
||||
wave(&mut v, x0 + (x1 - x0) * f / n, x0 + (x1 - x0) * (f + 1.0) / n, y, k);
|
||||
}
|
||||
v
|
||||
};
|
||||
Spec::Fill(guide(top_x, a), guide(bot_x, 1.0 - a))
|
||||
}
|
||||
|
||||
fn spec(name: &str, a: &Adj) -> Option<Vec<Spec>> {
|
||||
use Seg::*;
|
||||
let fill = |t: Vec<Seg>, b: Vec<Seg>| vec![Spec::Fill(t, b)];
|
||||
let top = || line(&[(0.0, 0.0), (1.0, 0.0)]);
|
||||
let bottom = || line(&[(0.0, 1.0), (1.0, 1.0)]);
|
||||
Some(match name {
|
||||
"textPlain" => {
|
||||
let d = 2.0 * (a.frac(0, 0.3, 0.7) - 0.5);
|
||||
if d < 0.0 {
|
||||
fill(line(&[(-d, 0.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0 + d, 1.0)]))
|
||||
} else {
|
||||
fill(line(&[(0.0, 0.0), (1.0 - d, 0.0)]), line(&[(d, 1.0), (1.0, 1.0)]))
|
||||
}
|
||||
}
|
||||
"textStop" => {
|
||||
let y = a.frac(0, 0.14286, 0.5);
|
||||
let t = 1.0 / 3.0;
|
||||
fill(line(&[(0.0, y), (t, 0.0), (1.0 - t, 0.0), (1.0, y)]), line(&[(0.0, 1.0 - y), (t, 1.0), (1.0 - t, 1.0), (1.0, 1.0 - y)]))
|
||||
}
|
||||
"textTriangle" => {
|
||||
let y = a.frac(0, 0.0, 1.0);
|
||||
fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), bottom())
|
||||
}
|
||||
"textTriangleInverted" => {
|
||||
let y = a.frac(0, 0.0, 1.0);
|
||||
fill(top(), line(&[(0.0, y), (0.5, 1.0), (1.0, y)]))
|
||||
}
|
||||
"textChevron" => {
|
||||
let y = a.frac(0, 0.0, 0.5);
|
||||
fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (0.5, 1.0 - y), (1.0, 1.0)]))
|
||||
}
|
||||
"textChevronInverted" => {
|
||||
let y = a.frac(0, 0.5, 1.0);
|
||||
fill(line(&[(0.0, 0.0), (0.5, 1.0 - y), (1.0, 0.0)]), line(&[(0.0, y), (0.5, 1.0), (1.0, y)]))
|
||||
}
|
||||
"textFadeRight" => {
|
||||
let y = a.frac(0, 0.0, 0.49999);
|
||||
fill(line(&[(0.0, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)]))
|
||||
}
|
||||
"textFadeLeft" => {
|
||||
let y = a.frac(0, 0.0, 0.49999);
|
||||
fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0 - y), (1.0, 1.0)]))
|
||||
}
|
||||
"textFadeUp" => {
|
||||
let x = a.frac(0, 0.0, 0.49999);
|
||||
fill(line(&[(x, 0.0), (1.0 - x, 0.0)]), bottom())
|
||||
}
|
||||
"textFadeDown" => {
|
||||
let x = a.frac(0, 0.0, 0.49999);
|
||||
fill(top(), line(&[(x, 1.0), (1.0 - x, 1.0)]))
|
||||
}
|
||||
"textSlantUp" => {
|
||||
let y = a.frac(0, 0.44444, 1.0);
|
||||
fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)]))
|
||||
}
|
||||
"textSlantDown" => {
|
||||
let y = a.frac(0, 0.0, 0.55556);
|
||||
fill(line(&[(0.0, 0.0), (1.0, 1.0 - y)]), line(&[(0.0, y), (1.0, 1.0)]))
|
||||
}
|
||||
"textCascadeUp" => {
|
||||
let y = a.frac(0, 0.0, 1.0);
|
||||
fill(line(&[(0.0, (1.0 - y) / 4.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, y)]))
|
||||
}
|
||||
"textCascadeDown" => {
|
||||
let y = a.frac(0, 0.0, 1.0);
|
||||
fill(line(&[(0.0, 0.0), (1.0, (1.0 - y) / 4.0)]), line(&[(0.0, y), (1.0, 1.0)]))
|
||||
}
|
||||
"textInflate" => {
|
||||
let y = a.frac(0, 0.0, 0.2);
|
||||
fill(quad(y, -y), quad(1.0 - y, 1.0 + y))
|
||||
}
|
||||
"textDeflate" => {
|
||||
let y = a.frac(0, 0.0, 0.375);
|
||||
fill(quad(0.0, 2.0 * y), quad(1.0, 1.0 - 2.0 * y))
|
||||
}
|
||||
"textInflateBottom" => {
|
||||
let y = a.frac(0, 0.6, 1.0);
|
||||
fill(top(), quad(y, 1.0))
|
||||
}
|
||||
"textDeflateBottom" => {
|
||||
let y = a.frac(0, 0.0625, 1.0);
|
||||
fill(top(), quad(1.0, 2.0 * y - 1.0))
|
||||
}
|
||||
"textInflateTop" => {
|
||||
let y = a.frac(0, 0.0, 0.5);
|
||||
fill(quad(y, 0.0), bottom())
|
||||
}
|
||||
"textDeflateTop" => {
|
||||
let y = a.frac(0, 0.0, 0.9375);
|
||||
fill(quad(0.0, 2.0 * y), bottom())
|
||||
}
|
||||
"textDeflateInflate" => {
|
||||
// The bands are split by a curve from mid-height up to `y`, with a gap of a tenth of
|
||||
// a band's height.
|
||||
let y = a.frac(0, 0.05, 0.95);
|
||||
let g = 0.05;
|
||||
vec![Spec::Fill(top(), quad(0.5 - g, 2.0 * y - 0.5 - g)), Spec::Fill(quad(0.5 + g, 2.0 * y - 0.5 + g), bottom())]
|
||||
}
|
||||
"textDeflateInflateDeflate" => {
|
||||
let y = a.frac(0, 0.01, 1.0 / 3.0);
|
||||
let g = 1.0 / 30.0;
|
||||
let (t1, t2) = (1.0 / 3.0, 2.0 / 3.0);
|
||||
let (c1, c2) = (2.0 * y - t1, 2.0 * (1.0 - y) - t2);
|
||||
vec![
|
||||
Spec::Fill(top(), quad(t1 - g, c1 - g)),
|
||||
Spec::Fill(quad(t1 + g, c1 + g), quad(t2 - g, c2 - g)),
|
||||
Spec::Fill(quad(t2 + g, c2 + g), bottom()),
|
||||
]
|
||||
}
|
||||
"textCurveUp" | "textCurveDown" => {
|
||||
let y = a.frac(0, 0.0, 0.56818);
|
||||
let b = vec![M(0.0, 1.0 - 3.0 * y / 8.0), C(1.0 / 3.0, 1.0 + y / 8.0, 2.0 / 3.0, 1.0 + y / 8.0, 1.0, 1.0 - 3.0 * y / 8.0)];
|
||||
let t = if name == "textCurveUp" {
|
||||
vec![M(0.0, y), C(1.0 / 3.0, 1.25 * y, 2.0 / 3.0, 0.75 * y, 1.0, 0.0)]
|
||||
} else {
|
||||
vec![M(0.0, 0.0), C(1.0 / 3.0, 0.75 * y, 2.0 / 3.0, 1.25 * y, 1.0, y)]
|
||||
};
|
||||
fill(t, b)
|
||||
}
|
||||
"textCanUp" => {
|
||||
let r = 1.0 - a.frac(0, 0.5, 1.0);
|
||||
fill(vec![A(0.5, r, 0.5, r, 180.0, 180.0)], vec![A(0.5, 1.0, 0.5, r, 180.0, 180.0)])
|
||||
}
|
||||
"textCanDown" => {
|
||||
let r = a.frac(0, 0.0, 0.5);
|
||||
fill(vec![A(0.5, 0.0, 0.5, r, 180.0, -180.0)], vec![A(0.5, 1.0 - r, 0.5, r, 180.0, -180.0)])
|
||||
}
|
||||
"textRingInside" | "textRingOutside" => {
|
||||
let h = a.frac(0, 0.5, 0.99);
|
||||
let sweep = if name == "textRingInside" { 360.0 } else { -360.0 };
|
||||
fill(vec![A(0.5, h / 2.0, 0.5, h / 2.0, 180.0, sweep)], vec![A(0.5, 1.0 - h / 2.0, 0.5, h / 2.0, 180.0, sweep)])
|
||||
}
|
||||
"textWave1" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, true)],
|
||||
"textWave2" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, false)],
|
||||
"textDoubleWave1" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, true)],
|
||||
"textWave4" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, false)],
|
||||
"textArchUp" => {
|
||||
let (s, w) = arch_up(a.angle(0));
|
||||
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
|
||||
}
|
||||
"textArchDown" => {
|
||||
let (s, w) = arch_down(a.angle(0));
|
||||
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::First)]
|
||||
}
|
||||
"textCircle" => {
|
||||
let (s, w) = circle(a.angle(0));
|
||||
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
|
||||
}
|
||||
"textButton" => {
|
||||
let t = a.angle(0);
|
||||
let (s, w) = arch_up(t);
|
||||
let half = 0.5 * t.to_radians().cos().abs();
|
||||
vec![
|
||||
Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last),
|
||||
Spec::Follow(line(&[(0.5 - half, 0.5), (0.5 + half, 0.5)]), LineAnchor::Middle),
|
||||
Spec::Follow(ellipse(1.0, 360.0 - s, -w), LineAnchor::First),
|
||||
]
|
||||
}
|
||||
"textArchUpPour" => {
|
||||
let (s, w) = arch_up(a.angle(0));
|
||||
let r = a.frac(1, 0.0, 0.99);
|
||||
fill(ellipse(1.0, s, w), ellipse(r, s, w))
|
||||
}
|
||||
"textArchDownPour" => {
|
||||
let (s, w) = arch_down(a.angle(0));
|
||||
let r = a.frac(1, 0.0, 0.99);
|
||||
fill(ellipse(r, s, w), ellipse(1.0, s, w))
|
||||
}
|
||||
"textCirclePour" => {
|
||||
let (s, w) = circle(a.angle(0));
|
||||
let r = a.frac(1, 0.0, 0.99);
|
||||
fill(ellipse(1.0, s, w), ellipse(r, s, w))
|
||||
}
|
||||
"textButtonPour" => {
|
||||
let (s, w) = arch_up(a.angle(0));
|
||||
let r = a.frac(1, 0.0, 0.99);
|
||||
// The middle band runs between chords of the inner ellipse halfway to its top and
|
||||
// bottom.
|
||||
let (dy, half) = (0.25 * r, 0.25 * r * 3f64.sqrt());
|
||||
vec![
|
||||
Spec::Fill(ellipse(1.0, s, w), ellipse(r, s, w)),
|
||||
Spec::Fill(line(&[(0.5 - half, 0.5 - dy), (0.5 + half, 0.5 - dy)]), line(&[(0.5 - half, 0.5 + dy), (0.5 + half, 0.5 + dy)])),
|
||||
Spec::Fill(ellipse(r, 360.0 - s, -w), ellipse(1.0, 360.0 - s, -w)),
|
||||
]
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a guide from unit-space segments mapped onto `r`.
|
||||
fn guide(segs: &[Seg], r: Rect) -> Guide {
|
||||
let mut p = BezPath::new();
|
||||
let mut started = false;
|
||||
let mut closed = false;
|
||||
for s in segs {
|
||||
match *s {
|
||||
Seg::M(x, y) => {
|
||||
p.move_to((x, y));
|
||||
started = true;
|
||||
}
|
||||
Seg::L(x, y) => {
|
||||
if started {
|
||||
p.line_to((x, y))
|
||||
} else {
|
||||
p.move_to((x, y))
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
Seg::Q(cx, cy, x, y) => {
|
||||
if started {
|
||||
p.quad_to((cx, cy), (x, y));
|
||||
}
|
||||
}
|
||||
Seg::C(x1, y1, x2, y2, x, y) => {
|
||||
if started {
|
||||
p.curve_to((x1, y1), (x2, y2), (x, y));
|
||||
}
|
||||
}
|
||||
Seg::A(cx, cy, rx, ry, start, sweep) => {
|
||||
let arc = Arc::new((cx, cy), (rx.max(0.0), ry.max(0.0)), start.to_radians(), sweep.to_radians(), 0.0);
|
||||
let s0 = Point::new(cx + rx * start.to_radians().cos(), cy + ry * start.to_radians().sin());
|
||||
if started {
|
||||
p.line_to(s0);
|
||||
} else {
|
||||
p.move_to(s0);
|
||||
}
|
||||
started = true;
|
||||
for el in arc.append_iter(1e-5) {
|
||||
p.push(el);
|
||||
}
|
||||
closed = sweep.abs() >= 359.999;
|
||||
}
|
||||
}
|
||||
}
|
||||
let m = kurbo::Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]);
|
||||
p.apply_affine(m);
|
||||
Guide::from_path(&p, closed)
|
||||
}
|
||||
|
||||
/// The warp `name` with adjust values `adj` (raw guide values; missing ones take the defaults)
|
||||
/// over text rectangle `rect`. `None` for unknown presets, `textNoShape` or a non-finite rect.
|
||||
pub fn warp(name: &str, adj: &[f64], rect: Rect) -> Option<Warp> {
|
||||
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let r = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1));
|
||||
let a = Adj { raw: adj, defaults: adjust_defaults(name) };
|
||||
let specs = spec(name, &a)?;
|
||||
let bands = specs
|
||||
.into_iter()
|
||||
.map(|s| match s {
|
||||
Spec::Fill(t, b) => Band::Fill { top: guide(&t, r), bottom: guide(&b, r) },
|
||||
Spec::Follow(p, anchor) => Band::Follow { path: guide(&p, r), anchor },
|
||||
})
|
||||
.collect();
|
||||
Some(Warp { bands })
|
||||
}
|
||||
|
||||
/// The band line `line` of `lines` goes to when there are `bands` bands: the first ⌈n/k⌉ lines
|
||||
/// share the first band, and so on.
|
||||
pub fn band_of_line(line: usize, lines: usize, bands: usize) -> usize {
|
||||
if bands <= 1 || lines == 0 {
|
||||
return 0;
|
||||
}
|
||||
// Band b holds lines ceil(b·n/k) .. ceil((b+1)·n/k).
|
||||
(0..bands).rev().find(|&b| (b * lines).div_ceil(bands) <= line).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// One laid-out line as the warp sees it (layout coordinates, points).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WarpLine {
|
||||
/// Ink box of the line's glyphs; `None` for a line without ink.
|
||||
pub ink: Option<Rect>,
|
||||
/// Advance extent of the line (left, right).
|
||||
pub extent: (f64, f64),
|
||||
/// Reference height for follow bands: the y that sits on the guide.
|
||||
pub ref_y: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum LineMap {
|
||||
Fill {
|
||||
band: usize,
|
||||
cx: f64,
|
||||
k: f64,
|
||||
len: f64,
|
||||
y0: f64,
|
||||
y1: f64,
|
||||
},
|
||||
/// `guide`: the line's own parallel guide in [`Mapper::offsets`], if it is off the band's.
|
||||
Follow {
|
||||
band: usize,
|
||||
guide: Option<usize>,
|
||||
e0: f64,
|
||||
s0: f64,
|
||||
k: f64,
|
||||
ref_y: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Maps points of laid-out lines onto a warp.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mapper<'a> {
|
||||
warp: &'a Warp,
|
||||
lines: Vec<LineMap>,
|
||||
offsets: Vec<Guide>,
|
||||
zoom: f64,
|
||||
}
|
||||
|
||||
impl Warp {
|
||||
/// Plan the mapping of `lines`, laid out in the inner text box `inner` (the line's place in
|
||||
/// it gives its alignment along follow guides; the insets themselves don't shorten a guide).
|
||||
pub fn mapper(&self, lines: &[WarpLine], inner: Rect) -> Mapper<'_> {
|
||||
let nb = self.bands.len().max(1);
|
||||
let n = lines.len();
|
||||
let band_of: Vec<usize> = (0..n).map(|i| band_of_line(i, n, nb).min(nb - 1)).collect();
|
||||
// Ink of the whole text, for the shared horizontal centre and scale of fill bands.
|
||||
let ink_all = lines.iter().filter_map(|l| l.ink).reduce(|a, b| a.union(b));
|
||||
let cx = ink_all.map(|r| r.center().x).unwrap_or(0.0);
|
||||
let mut k_fill = f64::INFINITY;
|
||||
let mut zoom: f64 = 1.0;
|
||||
let mut band_y: Vec<Option<(f64, f64)>> = vec![None; nb];
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let Some(r) = l.ink else { continue };
|
||||
let b = band_of.get(i).copied().unwrap_or(0);
|
||||
if let Some(slot) = band_y.get_mut(b) {
|
||||
*slot = Some(match *slot {
|
||||
Some((y0, y1)) => (y0.min(r.y0), y1.max(r.y1)),
|
||||
None => (r.y0, r.y1),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Every fill band maps a box as tall as the tallest band's ink, centred on its own ink, so
|
||||
// a band with fewer lines keeps them at the same size, centred between its guides.
|
||||
let tallest = band_y.iter().flatten().map(|(y0, y1)| y1 - y0).fold(0.0, f64::max);
|
||||
let band_y: Vec<Option<(f64, f64)>> =
|
||||
band_y.iter().map(|r| r.map(|(y0, y1)| ((y0 + y1 - tallest) / 2.0, (y0 + y1 + tallest) / 2.0))).collect();
|
||||
for (b, band) in self.bands.iter().enumerate() {
|
||||
if let Band::Fill { top, bottom } = band {
|
||||
let half = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| band_of.get(*i) == Some(&b))
|
||||
.filter_map(|(_, l)| l.ink)
|
||||
.map(|r| (r.x0 - cx).abs().max((r.x1 - cx).abs()))
|
||||
.fold(0.0, f64::max);
|
||||
if half > 1e-9 {
|
||||
k_fill = k_fill.min(band.length() / (2.0 * half));
|
||||
}
|
||||
if let Some(Some((y0, y1))) = band_y.get(b) {
|
||||
let thick = [0.0, 0.25, 0.5, 0.75, 1.0].iter().map(|&u| (top.at_frac(u) - bottom.at_frac(u)).hypot()).fold(0.0, f64::max);
|
||||
if y1 - y0 > 1e-9 {
|
||||
zoom = zoom.max(thick / (y1 - y0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !k_fill.is_finite() {
|
||||
k_fill = 1.0;
|
||||
}
|
||||
zoom = zoom.max(k_fill);
|
||||
let mut maps = Vec::with_capacity(n);
|
||||
let mut offsets = Vec::new();
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let b = band_of.get(i).copied().unwrap_or(0);
|
||||
let in_band = |j: &usize| band_of.get(*j) == Some(&b);
|
||||
match self.bands.get(b) {
|
||||
Some(Band::Fill { .. }) | None => {
|
||||
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
|
||||
let len = self.bands.get(b).map(Band::length).unwrap_or(0.0);
|
||||
maps.push(LineMap::Fill { band: b, cx, k: k_fill, len, y0, y1 });
|
||||
}
|
||||
Some(Band::Follow { path, anchor }) => {
|
||||
let len = path.length();
|
||||
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max);
|
||||
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
|
||||
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
|
||||
let ref_y = match anchor {
|
||||
LineAnchor::Last => members.last().map(|l| l.ref_y),
|
||||
LineAnchor::First => members.first().map(|l| l.ref_y),
|
||||
LineAnchor::Middle => match (members.first(), members.last()) {
|
||||
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
.unwrap_or(l.ref_y);
|
||||
// Lines off the guide run on a parallel curve at their natural length.
|
||||
let d = (ref_y - l.ref_y) * k;
|
||||
let (guide, len) = if d.abs() > 1e-6 && d.is_finite() && offsets.len() < 4096 {
|
||||
let g = path.offset(d);
|
||||
let len = g.length();
|
||||
offsets.push(g);
|
||||
(Some(offsets.len() - 1), len)
|
||||
} else {
|
||||
(None, len)
|
||||
};
|
||||
let (e0, e1) = l.extent;
|
||||
let w = (e1 - e0).max(0.0) * k;
|
||||
let (gl, gr) = ((e0 - inner.x0).max(0.0), (inner.x1 - e1).max(0.0));
|
||||
let f = if gl + gr > 1e-9 { gl / (gl + gr) } else { 0.5 };
|
||||
let s0 = f * (len - w);
|
||||
maps.push(LineMap::Follow { band: b, guide, e0, s0, k, ref_y: l.ref_y });
|
||||
}
|
||||
}
|
||||
}
|
||||
Mapper { warp: self, lines: maps, offsets, zoom: if zoom.is_finite() { zoom.clamp(1.0, 1.0e4) } else { 1.0 } }
|
||||
}
|
||||
}
|
||||
|
||||
impl Mapper<'_> {
|
||||
/// How much the warp magnifies text at most (for flattening tolerances), ≥ 1.
|
||||
pub fn zoom(&self) -> f64 {
|
||||
self.zoom
|
||||
}
|
||||
|
||||
/// Map point `p` of line `line` (layout coordinates) onto the warp.
|
||||
pub fn map(&self, line: usize, p: Point) -> Point {
|
||||
let out = match self.lines.get(line) {
|
||||
Some(&LineMap::Fill { band, cx, k, len, y0, y1 }) => {
|
||||
let Some(Band::Fill { top, bottom }) = self.warp.bands.get(band) else { return p };
|
||||
let u = if len > 1e-9 { 0.5 + (p.x - cx) * k / len } else { 0.5 };
|
||||
let v = if y1 - y0 > 1e-9 { (p.y - y0) / (y1 - y0) } else { 0.5 };
|
||||
let (t, b) = (top.at_frac(u), bottom.at_frac(u));
|
||||
t.lerp(b, v)
|
||||
}
|
||||
Some(&LineMap::Follow { band, guide, e0, s0, k, ref_y }) => {
|
||||
let path = match (guide.and_then(|g| self.offsets.get(g)), self.warp.bands.get(band)) {
|
||||
(Some(g), _) => g,
|
||||
(None, Some(Band::Follow { path, .. })) => path,
|
||||
_ => return p,
|
||||
};
|
||||
let s = s0 + (p.x - e0) * k;
|
||||
let d = (ref_y - p.y) * k;
|
||||
let (q, t) = path.at(s);
|
||||
q + Vec2::new(t.y, -t.x) * d
|
||||
}
|
||||
None => p,
|
||||
};
|
||||
if out.x.is_finite() && out.y.is_finite() { out } else { p }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,309 @@
|
||||
use super::*;
|
||||
|
||||
const ALL: &[&str] = &[
|
||||
"textPlain",
|
||||
"textStop",
|
||||
"textTriangle",
|
||||
"textTriangleInverted",
|
||||
"textChevron",
|
||||
"textChevronInverted",
|
||||
"textRingInside",
|
||||
"textRingOutside",
|
||||
"textArchUp",
|
||||
"textArchDown",
|
||||
"textCircle",
|
||||
"textButton",
|
||||
"textArchUpPour",
|
||||
"textArchDownPour",
|
||||
"textCirclePour",
|
||||
"textButtonPour",
|
||||
"textCurveUp",
|
||||
"textCurveDown",
|
||||
"textCanUp",
|
||||
"textCanDown",
|
||||
"textWave1",
|
||||
"textWave2",
|
||||
"textDoubleWave1",
|
||||
"textWave4",
|
||||
"textInflate",
|
||||
"textDeflate",
|
||||
"textInflateBottom",
|
||||
"textDeflateBottom",
|
||||
"textInflateTop",
|
||||
"textDeflateTop",
|
||||
"textDeflateInflate",
|
||||
"textDeflateInflateDeflate",
|
||||
"textFadeRight",
|
||||
"textFadeLeft",
|
||||
"textFadeUp",
|
||||
"textFadeDown",
|
||||
"textSlantUp",
|
||||
"textSlantDown",
|
||||
"textCascadeUp",
|
||||
"textCascadeDown",
|
||||
];
|
||||
|
||||
const W: f64 = 420.0;
|
||||
const H: f64 = 200.0;
|
||||
|
||||
fn rect() -> Rect {
|
||||
Rect::new(0.0, 0.0, W, H)
|
||||
}
|
||||
|
||||
/// One line whose ink box is `ink`.
|
||||
fn one_line(ink: Rect) -> Vec<WarpLine> {
|
||||
vec![WarpLine { ink: Some(ink), extent: (ink.x0, ink.x1), ref_y: ink.y1 }]
|
||||
}
|
||||
|
||||
/// Where the unit-square point (u, v) of a single line's ink box lands, in unit coordinates.
|
||||
fn land(name: &str, adj: &[f64], u: f64, v: f64) -> (f64, f64) {
|
||||
let w = warp(name, adj, rect()).unwrap();
|
||||
let ink = Rect::new(100.0, 50.0, 300.0, 90.0);
|
||||
let lines = one_line(ink);
|
||||
let m = w.mapper(&lines, rect().inset(-5.0));
|
||||
let p = m.map(0, Point::new(ink.x0 + u * ink.width(), ink.y0 + v * ink.height()));
|
||||
(p.x / W, p.y / H)
|
||||
}
|
||||
|
||||
fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool {
|
||||
(a.0 - b.0).abs() <= tol && (a.1 - b.1).abs() <= tol
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn corners(name: &str, adj: &[f64], tl: (f64, f64), tr: (f64, f64), bl: (f64, f64), br: (f64, f64)) {
|
||||
for ((u, v), want) in [((0.0, 0.0), tl), ((1.0, 0.0), tr), ((0.0, 1.0), bl), ((1.0, 1.0), br)] {
|
||||
let got = land(name, adj, u, v);
|
||||
assert!(close(got, want, 0.004), "{name} corner ({u},{v}): got {got:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_builds_finite_guides() {
|
||||
for name in ALL {
|
||||
assert!(is_supported(name), "{name}");
|
||||
let w = warp(name, &[], rect()).unwrap_or_else(|| panic!("{name}"));
|
||||
assert!(!w.bands.is_empty(), "{name}");
|
||||
for b in &w.bands {
|
||||
let l = b.length();
|
||||
assert!(l.is_finite() && l > 10.0, "{name}: band length {l}");
|
||||
}
|
||||
assert_eq!(adjust_count(name), adjust_defaults(name).len());
|
||||
}
|
||||
assert!(warp("textNoShape", &[], rect()).is_none());
|
||||
assert!(warp("bogus", &[], rect()).is_none());
|
||||
assert!(!is_supported("textNoShape"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_presets_put_the_ink_corners_on_their_guides() {
|
||||
corners("textPlain", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0));
|
||||
// A smaller adjust shears the text: the top edge starts 40 % in, the bottom edge ends 60 % in.
|
||||
corners("textPlain", &[30_000.0], (0.4, 0.0), (1.0, 0.0), (0.0, 1.0), (0.6, 1.0));
|
||||
corners("textStop", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 0.75), (1.0, 0.75));
|
||||
corners("textTriangle", &[], (0.0, 0.5), (1.0, 0.5), (0.0, 1.0), (1.0, 1.0));
|
||||
corners("textTriangleInverted", &[25_000.0], (0.0, 0.0), (1.0, 0.0), (0.0, 0.25), (1.0, 0.25));
|
||||
corners("textChevron", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 1.0), (1.0, 1.0));
|
||||
corners("textChevronInverted", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.75), (1.0, 0.75));
|
||||
corners("textFadeRight", &[], (0.0, 0.0), (1.0, 0.33333), (0.0, 1.0), (1.0, 0.66667));
|
||||
corners("textFadeLeft", &[], (0.0, 0.33333), (1.0, 0.0), (0.0, 0.66667), (1.0, 1.0));
|
||||
corners("textFadeUp", &[], (0.33333, 0.0), (0.66667, 0.0), (0.0, 1.0), (1.0, 1.0));
|
||||
corners("textFadeDown", &[], (0.0, 0.0), (1.0, 0.0), (0.33333, 1.0), (0.66667, 1.0));
|
||||
corners("textSlantUp", &[], (0.0, 0.55556), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444));
|
||||
corners("textSlantDown", &[], (0.0, 0.0), (1.0, 0.55556), (0.0, 0.44444), (1.0, 1.0));
|
||||
corners("textCascadeUp", &[], (0.0, 0.13889), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444));
|
||||
corners("textCascadeDown", &[], (0.0, 0.0), (1.0, 0.13889), (0.0, 0.44444), (1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_presets_bend_where_powerpoint_bends_them() {
|
||||
corners("textInflate", &[], (0.0, 0.1875), (1.0, 0.1875), (0.0, 0.8125), (1.0, 0.8125));
|
||||
assert!(close(land("textInflate", &[], 0.5, 0.0), (0.5, 0.0), 0.004));
|
||||
assert!(close(land("textInflate", &[], 0.5, 1.0), (0.5, 1.0), 0.004));
|
||||
corners("textDeflate", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0));
|
||||
assert!(close(land("textDeflate", &[], 0.5, 0.0), (0.5, 0.1875), 0.004));
|
||||
corners("textInflateBottom", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.6), (1.0, 0.6));
|
||||
assert!(close(land("textInflateBottom", &[], 0.5, 1.0), (0.5, 0.8), 0.004));
|
||||
assert!(close(land("textDeflateBottom", &[], 0.5, 1.0), (0.5, 0.5), 0.004));
|
||||
assert!(close(land("textInflateTop", &[], 0.5, 0.0), (0.5, 0.2), 0.004));
|
||||
assert!(close(land("textDeflateTop", &[], 0.5, 0.0), (0.5, 0.5), 0.004));
|
||||
// Cans: half ellipses.
|
||||
corners("textCanUp", &[], (0.0, 0.15), (1.0, 0.15), (0.0, 1.0), (1.0, 1.0));
|
||||
assert!(close(land("textCanUp", &[], 0.5, 0.0), (0.5, 0.0), 0.004));
|
||||
assert!(close(land("textCanUp", &[], 0.5, 1.0), (0.5, 0.85), 0.004));
|
||||
assert!(close(land("textCanDown", &[], 0.5, 0.0), (0.5, 0.14286), 0.004));
|
||||
// Curves and waves.
|
||||
let a = 0.45977;
|
||||
corners("textCurveUp", &[], (0.0, a), (1.0, 0.0), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0));
|
||||
assert!(close(land("textCurveUp", &[], 0.5, 1.0), (0.5, 1.0), 0.004));
|
||||
corners("textCurveDown", &[], (0.0, 0.0), (1.0, a), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0));
|
||||
corners("textWave1", &[], (0.0, 0.125), (1.0, 0.125), (0.0, 0.875), (1.0, 0.875));
|
||||
// Wave 1 rises first: its top is near the box top a quarter of the way along.
|
||||
assert!(land("textWave1", &[], 0.25, 0.0).1 < 0.02);
|
||||
assert!(land("textWave2", &[], 0.25, 0.0).1 > 0.23);
|
||||
// A horizontal shift moves the bottom guide right.
|
||||
corners("textWave1", &[12_500.0, 5_000.0], (0.0, 0.125), (0.9, 0.125), (0.1, 0.875), (1.0, 0.875));
|
||||
assert!(land("textDoubleWave1", &[], 0.125, 0.0).1 < 0.02);
|
||||
assert!(land("textWave4", &[], 0.125, 0.0).1 > 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_presets_follow_ellipses() {
|
||||
// Pour arches: the outer arc from the left end over the top; the inner one is scaled by adj2.
|
||||
corners("textArchUpPour", &[], (0.0, 0.5), (1.0, 0.5), (0.25, 0.5), (0.75, 0.5));
|
||||
assert!(close(land("textArchUpPour", &[], 0.5, 0.0), (0.5, 0.0), 0.004));
|
||||
assert!(close(land("textArchUpPour", &[], 0.5, 1.0), (0.5, 0.25), 0.004));
|
||||
assert!(close(land("textArchUpPour", &[10_800_000.0, 30_000.0], 0.5, 1.0), (0.5, 0.35), 0.004));
|
||||
assert!(close(land("textArchDownPour", &[], 0.5, 0.0), (0.5, 0.625), 0.004));
|
||||
assert!(close(land("textArchDownPour", &[], 0.5, 1.0), (0.5, 1.0), 0.004));
|
||||
// Circle pour: once round from the left; the middle of the text is at the right.
|
||||
assert!(close(land("textCirclePour", &[], 0.5, 0.0), (1.0, 0.5), 0.004));
|
||||
// Rings: the text goes round two stacked ellipses.
|
||||
assert!(close(land("textRingInside", &[], 0.0, 0.0), (0.0, 0.3), 0.004));
|
||||
assert!(close(land("textRingInside", &[], 0.0, 1.0), (0.0, 0.7), 0.004));
|
||||
assert!(close(land("textRingInside", &[], 0.25, 0.0), (0.5, 0.0), 0.01));
|
||||
assert!(close(land("textRingOutside", &[], 0.25, 1.0), (0.5, 1.0), 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_presets_keep_text_size_and_stack_lines_outwards() {
|
||||
let w = warp("textArchUp", &[], rect()).unwrap();
|
||||
// A 40-wide centred line whose reference height is 80.
|
||||
let line = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 88.0)), extent: (190.0, 230.0), ref_y: 80.0 };
|
||||
let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6);
|
||||
let m = w.mapper(&[line], inner);
|
||||
// The middle of the reference line lands on the top of the ellipse; 10 pt above it lands
|
||||
// 10 pt above the top.
|
||||
let p = m.map(0, Point::new(210.0, 80.0));
|
||||
assert!((p.x - 210.0).abs() < 0.05 && p.y.abs() < 0.05, "{p:?}");
|
||||
let q = m.map(0, Point::new(210.0, 70.0));
|
||||
assert!((q.x - 210.0).abs() < 0.15 && (q.y + 10.0).abs() < 0.05, "{q:?}");
|
||||
// Natural size: 40 pt of text spans about 40 pt of arc.
|
||||
let (a, b) = (m.map(0, Point::new(190.0, 80.0)), m.map(0, Point::new(230.0, 80.0)));
|
||||
assert!(((b - a).hypot() - 40.0).abs() < 0.5);
|
||||
// Left-aligned text starts at the very start of the arc: insets don't shorten it.
|
||||
let left = WarpLine { ink: Some(Rect::new(7.2, 50.0, 47.2, 88.0)), extent: (7.2, 47.2), ref_y: 80.0 };
|
||||
let m = w.mapper(&[left], inner);
|
||||
let p = m.map(0, Point::new(7.2, 80.0));
|
||||
assert!(p.x.abs() < 0.01 && (p.y - 100.0).abs() < 0.01, "{p:?}");
|
||||
// Two lines on an arch up: the last sits on the arc, the first stacks outside it.
|
||||
let l1 = WarpLine { ink: Some(Rect::new(190.0, 10.0, 230.0, 40.0)), extent: (190.0, 230.0), ref_y: 34.0 };
|
||||
let l2 = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 80.0)), extent: (190.0, 230.0), ref_y: 74.0 };
|
||||
let m = w.mapper(&[l1, l2], inner);
|
||||
assert!(m.map(1, Point::new(210.0, 74.0)).y.abs() < 0.05);
|
||||
assert!((m.map(0, Point::new(210.0, 34.0)).y + 40.0).abs() < 0.05);
|
||||
// Arch down: the first line sits on the bottom of the ellipse.
|
||||
let w = warp("textArchDown", &[], rect()).unwrap();
|
||||
let m = w.mapper(&[l1, l2], inner);
|
||||
assert!((m.map(0, Point::new(210.0, 34.0)).y - H).abs() < 0.05);
|
||||
assert!((m.map(1, Point::new(210.0, 74.0)).y - H - 40.0).abs() < 0.05);
|
||||
// Text longer than the arc is shrunk to fit it.
|
||||
let w = warp("textArchUp", &[], rect()).unwrap();
|
||||
let long = WarpLine { ink: Some(Rect::new(-500.0, 50.0, 900.0, 88.0)), extent: (-500.0, 900.0), ref_y: 80.0 };
|
||||
let m = w.mapper(&[long], inner);
|
||||
let (a, b) = (m.map(0, Point::new(-500.0, 80.0)), m.map(0, Point::new(900.0, 80.0)));
|
||||
assert!(a.x < 5.0 && b.x > W - 5.0 && (a.y - 100.0).abs() < 10.0 && (b.y - 100.0).abs() < 10.0, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_share_bands_in_order() {
|
||||
assert_eq!((0..2).map(|i| band_of_line(i, 2, 2)).collect::<Vec<_>>(), [0, 1]);
|
||||
assert_eq!((0..3).map(|i| band_of_line(i, 3, 2)).collect::<Vec<_>>(), [0, 0, 1]);
|
||||
assert_eq!((0..5).map(|i| band_of_line(i, 5, 2)).collect::<Vec<_>>(), [0, 0, 0, 1, 1]);
|
||||
assert_eq!((0..5).map(|i| band_of_line(i, 5, 3)).collect::<Vec<_>>(), [0, 0, 1, 1, 2]);
|
||||
assert_eq!((0..4).map(|i| band_of_line(i, 4, 3)).collect::<Vec<_>>(), [0, 0, 1, 2]);
|
||||
assert_eq!((0..2).map(|i| band_of_line(i, 2, 3)).collect::<Vec<_>>(), [0, 1]);
|
||||
assert_eq!(band_of_line(0, 1, 3), 0);
|
||||
assert_eq!(band_of_line(7, 9, 1), 0);
|
||||
assert_eq!(band_of_line(0, 0, 3), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deflate_inflate_maps_each_band_separately() {
|
||||
let w = warp("textDeflateInflate", &[], rect()).unwrap();
|
||||
assert_eq!(w.bands.len(), 2);
|
||||
let l1 = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 40.0)), extent: (100.0, 300.0), ref_y: 34.0 };
|
||||
let l2 = WarpLine { ink: Some(Rect::new(100.0, 50.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 74.0 };
|
||||
let m = w.mapper(&[l1, l2], rect());
|
||||
// Band 1 ends at 45 % height on the sides, band 2 starts at 55 %.
|
||||
assert!((m.map(0, Point::new(100.0, 40.0)).y / H - 0.45).abs() < 0.004);
|
||||
assert!((m.map(1, Point::new(100.0, 50.0)).y / H - 0.55).abs() < 0.004);
|
||||
assert!((m.map(1, Point::new(300.0, 80.0)).y / H - 1.0).abs() < 0.004);
|
||||
// A band with fewer lines keeps them at the size of the fuller band, centred in its band:
|
||||
// two lines (ink 10..60) in band 1, one line (ink 70..80) in band 2 of three.
|
||||
let a = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 30.0)), extent: (100.0, 300.0), ref_y: 28.0 };
|
||||
let b = WarpLine { ink: Some(Rect::new(100.0, 40.0, 300.0, 60.0)), extent: (100.0, 300.0), ref_y: 58.0 };
|
||||
let c = WarpLine { ink: Some(Rect::new(100.0, 70.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 78.0 };
|
||||
let m = w.mapper(&[a, b, c], rect());
|
||||
let (top, bot) = (m.map(2, Point::new(200.0, 70.0)).y / H, m.map(2, Point::new(200.0, 80.0)).y / H);
|
||||
// Band 2 runs from 0.4 to 1 at the centre; the line takes the middle fifth of it.
|
||||
assert!((top - 0.64).abs() < 0.005 && (bot - 0.76).abs() < 0.005, "{top} {bot}");
|
||||
// A shorter first line keeps its share of the width.
|
||||
let short = WarpLine { ink: Some(Rect::new(150.0, 10.0, 250.0, 40.0)), extent: (150.0, 250.0), ref_y: 34.0 };
|
||||
let m = w.mapper(&[short, l2], rect());
|
||||
let p = m.map(0, Point::new(150.0, 10.0));
|
||||
assert!((p.x / W - 0.25).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_extrapolate_and_wrap() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((10.0, 0.0));
|
||||
let g = Guide::from_path(&p, false);
|
||||
assert!((g.length() - 10.0).abs() < 1e-9);
|
||||
assert!((g.at(15.0).0.x - 15.0).abs() < 1e-9);
|
||||
assert!((g.at(-5.0).0.x + 5.0).abs() < 1e-9);
|
||||
assert!(g.at(f64::NAN).0.x.is_finite());
|
||||
let w = warp("textCircle", &[], rect()).unwrap();
|
||||
let Some(Band::Follow { path, .. }) = w.bands.first() else { panic!() };
|
||||
assert!(path.is_closed());
|
||||
let l = path.length();
|
||||
let (a, b) = (path.at(5.0).0, path.at(l + 5.0).0);
|
||||
assert!((a - b).hypot() < 1e-6);
|
||||
let empty = Guide::default();
|
||||
assert_eq!(empty.length(), 0.0);
|
||||
assert!(empty.at(3.0).0.x.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_adjusts_and_boxes_stay_finite() {
|
||||
let adjs: &[&[f64]] =
|
||||
&[&[], &[f64::NAN, f64::INFINITY], &[f64::NEG_INFINITY, f64::NAN], &[1e300, -1e300], &[-1e9, 1e9], &[0.0, 0.0], &[21_599_999.0, 100_000.0]];
|
||||
let rects = [
|
||||
Rect::new(0.0, 0.0, 0.0, 0.0),
|
||||
Rect::new(10.0, 10.0, 10.0, 200.0),
|
||||
Rect::new(0.0, 0.0, 300.0, 0.0),
|
||||
Rect::new(100.0, 100.0, -50.0, -20.0),
|
||||
Rect::new(0.0, 0.0, 1e7, 1e7),
|
||||
];
|
||||
let lines = [
|
||||
WarpLine { ink: Some(Rect::new(0.0, 0.0, 0.0, 0.0)), extent: (0.0, 0.0), ref_y: 0.0 },
|
||||
WarpLine { ink: None, extent: (5.0, 5.0), ref_y: 3.0 },
|
||||
WarpLine { ink: Some(Rect::new(-1e6, -5.0, 1e6, 5.0)), extent: (-1e6, 1e6), ref_y: 4.0 },
|
||||
];
|
||||
for name in ALL {
|
||||
for adj in adjs {
|
||||
for r in rects {
|
||||
let Some(w) = warp(name, adj, r) else { panic!("{name} {adj:?} {r:?}") };
|
||||
for b in &w.bands {
|
||||
assert!(b.length().is_finite());
|
||||
}
|
||||
let m = w.mapper(&lines, r);
|
||||
assert!(m.zoom().is_finite() && m.zoom() >= 1.0);
|
||||
let probes = [
|
||||
(0, Point::ZERO),
|
||||
(1, Point::new(5.0, 3.0)),
|
||||
(2, Point::new(1e6, -5.0)),
|
||||
(2, Point::new(f64::NAN, 1.0)),
|
||||
(9, Point::new(1.0, 1.0)),
|
||||
];
|
||||
for (i, p) in probes {
|
||||
let q = m.map(i, p);
|
||||
assert!(q.x.is_finite() || !p.x.is_finite(), "{name} {adj:?} {r:?}: {q:?}");
|
||||
assert!(q.y.is_finite(), "{name} {adj:?} {r:?}: {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none());
|
||||
}
|
||||
+47
-12
@@ -150,7 +150,7 @@ pub struct HrdParameters {
|
||||
}
|
||||
|
||||
fn parse_hrd(r: &mut BitReader) -> Result<HrdParameters> {
|
||||
let cpb_cnt = r.read_ue()? + 1;
|
||||
let cpb_cnt = r.read_ue_plus(1)?;
|
||||
ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range");
|
||||
let mut h = HrdParameters { cpb_cnt, bit_rate_scale: r.read_bits(4)? as u8, cpb_size_scale: r.read_bits(4)? as u8, ..Default::default() };
|
||||
for _ in 0..cpb_cnt {
|
||||
@@ -353,8 +353,8 @@ impl Sps {
|
||||
if chroma_format_idc == 3 {
|
||||
separate_colour_plane = r.read_flag()?;
|
||||
}
|
||||
bit_depth_luma = r.read_ue()? + 8;
|
||||
bit_depth_chroma = r.read_ue()? + 8;
|
||||
bit_depth_luma = r.read_ue_plus(8)?;
|
||||
bit_depth_chroma = r.read_ue_plus(8)?;
|
||||
ensure!(bit_depth_luma <= 14 && bit_depth_chroma <= 14, "bit depth out of range");
|
||||
bypass = r.read_flag()?;
|
||||
seq_scaling_matrix_present = r.read_flag()?;
|
||||
@@ -363,7 +363,7 @@ impl Sps {
|
||||
scaling_lists = parse_scaling_lists(&mut r, count, None)?;
|
||||
}
|
||||
}
|
||||
let log2_max_frame_num = r.read_ue()? + 4;
|
||||
let log2_max_frame_num = r.read_ue_plus(4)?;
|
||||
ensure!(log2_max_frame_num <= 16, "log2_max_frame_num out of range");
|
||||
let pic_order_cnt_type = r.read_ue()?;
|
||||
ensure!(pic_order_cnt_type <= 2, "pic_order_cnt_type out of range");
|
||||
@@ -373,7 +373,7 @@ impl Sps {
|
||||
let mut offset_for_top_to_bottom_field = 0;
|
||||
let mut offset_for_ref_frame = Vec::new();
|
||||
if pic_order_cnt_type == 0 {
|
||||
log2_max_poc_lsb = r.read_ue()? + 4;
|
||||
log2_max_poc_lsb = r.read_ue_plus(4)?;
|
||||
ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb out of range");
|
||||
} else if pic_order_cnt_type == 1 {
|
||||
delta_pic_order_always_zero = r.read_flag()?;
|
||||
@@ -388,8 +388,8 @@ impl Sps {
|
||||
let max_num_ref_frames = r.read_ue()?;
|
||||
ensure!(max_num_ref_frames <= 16, "max_num_ref_frames out of range");
|
||||
let gaps_in_frame_num_allowed = r.read_flag()?;
|
||||
let pic_width_in_mbs = r.read_ue()? + 1;
|
||||
let pic_height_in_map_units = r.read_ue()? + 1;
|
||||
let pic_width_in_mbs = r.read_ue_plus(1)?;
|
||||
let pic_height_in_map_units = r.read_ue_plus(1)?;
|
||||
ensure!(pic_width_in_mbs <= 1024 && pic_height_in_map_units <= 1024, "picture too large");
|
||||
let frame_mbs_only = r.read_flag()?;
|
||||
let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false };
|
||||
@@ -429,7 +429,8 @@ impl Sps {
|
||||
};
|
||||
if let Some((l, rr, t, b)) = sps.frame_crop {
|
||||
let (cx, cy) = sps.crop_unit();
|
||||
ensure!((l + rr) * cx < sps.width() && (t + b) * cy < sps.height(), "frame cropping exceeds picture size");
|
||||
let crop = |a: u32, b: u32, unit: u32| (u64::from(a) + u64::from(b)) * u64::from(unit);
|
||||
ensure!(crop(l, rr, cx) < u64::from(sps.width()) && crop(t, b, cy) < u64::from(sps.height()), "frame cropping exceeds picture size");
|
||||
}
|
||||
Ok(sps)
|
||||
}
|
||||
@@ -572,7 +573,7 @@ impl Pps {
|
||||
.ok_or_else(|| crate::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?;
|
||||
let entropy_coding_mode = r.read_flag()?;
|
||||
let bottom_field_pic_order_in_frame_present = r.read_flag()?;
|
||||
let num_slice_groups = r.read_ue()? + 1;
|
||||
let num_slice_groups = r.read_ue_plus(1)?;
|
||||
ensure!(num_slice_groups <= 8, "num_slice_groups out of range");
|
||||
let mut slice_group_map_type = 0;
|
||||
if num_slice_groups > 1 {
|
||||
@@ -595,7 +596,7 @@ impl Pps {
|
||||
r.read_ue()?;
|
||||
}
|
||||
6 => {
|
||||
let n = r.read_ue()? + 1;
|
||||
let n = r.read_ue_plus(1)?;
|
||||
let bits = 32 - (num_slice_groups - 1).leading_zeros();
|
||||
for _ in 0..n {
|
||||
r.read_bits(bits)?;
|
||||
@@ -604,8 +605,8 @@ impl Pps {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let num_ref_idx_l0_default_active = r.read_ue()? + 1;
|
||||
let num_ref_idx_l1_default_active = r.read_ue()? + 1;
|
||||
let num_ref_idx_l0_default_active = r.read_ue_plus(1)?;
|
||||
let num_ref_idx_l1_default_active = r.read_ue_plus(1)?;
|
||||
ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range");
|
||||
let weighted_pred = r.read_flag()?;
|
||||
let weighted_bipred_idc = r.read_bits(2)?;
|
||||
@@ -716,4 +717,38 @@ mod tests {
|
||||
assert_eq!(sps.pic_order_cnt_type, 2);
|
||||
assert_eq!(sps.crop_rect(), (0, 0, 176, 144));
|
||||
}
|
||||
|
||||
/// The baseline SPS of `parse_minimal_baseline_sps` with a chosen width and optional cropping.
|
||||
fn sps_with(width_minus1: u32, crop: Option<[u32; 4]>) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(66, 8);
|
||||
w.write_bits(0xC0, 8);
|
||||
w.write_bits(30, 8);
|
||||
w.write_ue(0);
|
||||
w.write_ue(0);
|
||||
w.write_ue(2);
|
||||
w.write_ue(1);
|
||||
w.write_bit(false);
|
||||
w.write_ue(width_minus1);
|
||||
w.write_ue(8);
|
||||
w.write_bit(true); // frame_mbs_only
|
||||
w.write_bit(true); // direct_8x8
|
||||
w.write_bit(crop.is_some());
|
||||
if let Some(c) = crop {
|
||||
c.iter().for_each(|&v| w.write_ue(v));
|
||||
}
|
||||
w.write_bit(false); // vui
|
||||
w.rbsp_trailing();
|
||||
w.finish()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sps_values_are_errors_not_overflows() {
|
||||
// pic_width_in_mbs_minus1 = u32::MAX: `+ 1` used to overflow (wrapping to 0 in release).
|
||||
assert!(Sps::parse(&sps_with(u32::MAX, None)).is_err());
|
||||
// Crop offsets whose sum or product exceed u32.
|
||||
assert!(Sps::parse(&sps_with(10, Some([u32::MAX - 1, u32::MAX - 1, 0, 0]))).is_err());
|
||||
assert!(Sps::parse(&sps_with(10, Some([0x8000_0000, 0, 0, 0]))).is_err());
|
||||
assert!(Sps::parse(&sps_with(10, Some([1, 1, 1, 1]))).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,9 +199,9 @@ impl SliceHeader {
|
||||
let mut num_ref_idx_active = [pps.num_ref_idx_l0_default_active, pps.num_ref_idx_l1_default_active];
|
||||
if !slice_type.is_intra() {
|
||||
if r.read_flag()? {
|
||||
num_ref_idx_active[0] = r.read_ue()? + 1;
|
||||
num_ref_idx_active[0] = r.read_ue_plus(1)?;
|
||||
if slice_type.is_b() {
|
||||
num_ref_idx_active[1] = r.read_ue()? + 1;
|
||||
num_ref_idx_active[1] = r.read_ue_plus(1)?;
|
||||
}
|
||||
}
|
||||
let max = if field_pic { 32 } else { 16 };
|
||||
|
||||
+92
-25
@@ -97,7 +97,7 @@ pub fn skip_hrd(r: &mut BitReader, common: bool, max_sub_layers_minus1: u32) ->
|
||||
}
|
||||
let mut cpb_cnt = 1;
|
||||
if !low_delay {
|
||||
cpb_cnt = r.read_ue()? + 1;
|
||||
cpb_cnt = r.read_ue_plus(1)?;
|
||||
ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range");
|
||||
}
|
||||
if nal {
|
||||
@@ -259,7 +259,7 @@ impl ScalingList {
|
||||
while m < 6 {
|
||||
let coef_num = if size == 0 { 16 } else { 64 };
|
||||
if !r.read_flag()? {
|
||||
let delta = r.read_ue()? as usize * step;
|
||||
let delta = (r.read_ue()? as usize).saturating_mul(step);
|
||||
ensure!(delta <= m, "scaling_list_pred_matrix_id_delta out of range");
|
||||
if delta == 0 {
|
||||
// default list
|
||||
@@ -349,12 +349,13 @@ impl StRps {
|
||||
pub fn parse(r: &mut BitReader, idx: usize, sets: &[StRps], num_sets: usize) -> Result<StRps> {
|
||||
let inter = if idx != 0 { r.read_flag()? } else { false };
|
||||
if inter {
|
||||
let delta_idx = if idx == num_sets { r.read_ue()? as usize + 1 } else { 1 };
|
||||
let delta_idx = if idx == num_sets { r.read_ue_plus(1)? as usize } else { 1 };
|
||||
ensure!(delta_idx <= idx, "delta_idx_minus1 out of range");
|
||||
let rf = &sets[idx - delta_idx];
|
||||
let sign = r.read_flag()?;
|
||||
let abs = r.read_ue()? as i32 + 1;
|
||||
let abs = r.read_ue_plus(1)?;
|
||||
ensure!(abs <= 1 << 15, "abs_delta_rps_minus1 out of range");
|
||||
let abs = abs as i32;
|
||||
let delta_rps = if sign { -abs } else { abs };
|
||||
let n = rf.num_delta_pocs();
|
||||
let mut used = vec![false; n + 1];
|
||||
@@ -406,15 +407,17 @@ impl StRps {
|
||||
let mut out = StRps::default();
|
||||
let mut poc = 0i32;
|
||||
for _ in 0..nneg {
|
||||
let d = r.read_ue()? as i32 + 1;
|
||||
let d = r.read_ue_plus(1)?;
|
||||
ensure!(d <= 1 << 15, "delta_poc_s0_minus1 out of range");
|
||||
let d = d as i32;
|
||||
poc -= d;
|
||||
out.s0.push((poc, r.read_flag()?));
|
||||
}
|
||||
poc = 0;
|
||||
for _ in 0..npos {
|
||||
let d = r.read_ue()? as i32 + 1;
|
||||
let d = r.read_ue_plus(1)?;
|
||||
ensure!(d <= 1 << 15, "delta_poc_s1_minus1 out of range");
|
||||
let d = d as i32;
|
||||
poc += d;
|
||||
out.s1.push((poc, r.read_flag()?));
|
||||
}
|
||||
@@ -491,25 +494,25 @@ impl Sps {
|
||||
if r.read_flag()? {
|
||||
conf_win = (r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?);
|
||||
}
|
||||
let bit_depth_luma = r.read_ue()? + 8;
|
||||
let bit_depth_chroma = r.read_ue()? + 8;
|
||||
let bit_depth_luma = r.read_ue_plus(8)?;
|
||||
let bit_depth_chroma = r.read_ue_plus(8)?;
|
||||
ensure!(bit_depth_luma <= 16 && bit_depth_chroma <= 16, "bit depth out of range");
|
||||
let log2_max_poc_lsb = r.read_ue()? + 4;
|
||||
let log2_max_poc_lsb = r.read_ue_plus(4)?;
|
||||
ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb_minus4 out of range");
|
||||
let ordering_all = r.read_flag()?;
|
||||
let (mut dpb, mut reorder, mut latency) = (1, 0, 0);
|
||||
let start = if ordering_all { 0 } else { max_sub_layers_minus1 };
|
||||
for _ in start..=max_sub_layers_minus1 {
|
||||
// the values of the highest sub-layer are used
|
||||
dpb = r.read_ue()? + 1;
|
||||
dpb = r.read_ue_plus(1)?;
|
||||
reorder = r.read_ue()?;
|
||||
latency = r.read_ue()?;
|
||||
}
|
||||
ensure!(dpb <= 16 && reorder <= 16, "sps_max_dec_pic_buffering out of range");
|
||||
let log2_min_cb = r.read_ue()? + 3;
|
||||
let log2_ctb = log2_min_cb + r.read_ue()?;
|
||||
let log2_min_tb = r.read_ue()? + 2;
|
||||
let log2_max_tb = log2_min_tb + r.read_ue()?;
|
||||
let log2_min_cb = r.read_ue_plus(3)?;
|
||||
let log2_ctb = log2_min_cb.saturating_add(r.read_ue()?);
|
||||
let log2_min_tb = r.read_ue_plus(2)?;
|
||||
let log2_max_tb = log2_min_tb.saturating_add(r.read_ue()?);
|
||||
ensure!((4..=6).contains(&log2_ctb) && log2_min_cb <= log2_ctb, "CTB size out of range");
|
||||
ensure!(log2_min_tb < log2_min_cb && log2_max_tb <= log2_ctb.min(5), "transform block sizes out of range");
|
||||
ensure!(width % (1 << log2_min_cb) == 0 && height % (1 << log2_min_cb) == 0, "picture size not a multiple of MinCbSizeY");
|
||||
@@ -532,8 +535,8 @@ impl Sps {
|
||||
if pcm {
|
||||
pcm_bit_depth_luma = r.read_bits(4)? + 1;
|
||||
pcm_bit_depth_chroma = r.read_bits(4)? + 1;
|
||||
log2_min_pcm = r.read_ue()? + 3;
|
||||
log2_max_pcm = log2_min_pcm + r.read_ue()?;
|
||||
log2_min_pcm = r.read_ue_plus(3)?;
|
||||
log2_max_pcm = log2_min_pcm.saturating_add(r.read_ue()?);
|
||||
pcm_loop_filter_disabled = r.read_flag()?;
|
||||
ensure!(pcm_bit_depth_luma <= bit_depth_luma && pcm_bit_depth_chroma <= bit_depth_chroma, "PCM bit depth out of range");
|
||||
ensure!(log2_max_pcm <= log2_ctb.min(5), "PCM size out of range");
|
||||
@@ -637,10 +640,10 @@ impl Sps {
|
||||
pub fn crop_rect(&self) -> (u32, u32, u32, u32) {
|
||||
let (l, r, t, b) = self.conf_win;
|
||||
let (sw, sh) = (self.sub_width_c(), self.sub_height_c());
|
||||
let x = (l * sw).min(self.width - 1);
|
||||
let y = (t * sh).min(self.height - 1);
|
||||
let w = self.width.saturating_sub(x + r * sw).max(1);
|
||||
let h = self.height.saturating_sub(y + b * sh).max(1);
|
||||
let x = l.saturating_mul(sw).min(self.width - 1);
|
||||
let y = t.saturating_mul(sh).min(self.height - 1);
|
||||
let w = self.width.saturating_sub(x.saturating_add(r.saturating_mul(sw))).max(1);
|
||||
let h = self.height.saturating_sub(y.saturating_add(b.saturating_mul(sh))).max(1);
|
||||
(x, y, w, h)
|
||||
}
|
||||
|
||||
@@ -720,8 +723,8 @@ impl Pps {
|
||||
let num_extra_slice_header_bits = r.read_bits(3)?;
|
||||
let sign_data_hiding = r.read_flag()?;
|
||||
let cabac_init_present = r.read_flag()?;
|
||||
let num_ref_idx_l0_default = r.read_ue()? + 1;
|
||||
let num_ref_idx_l1_default = r.read_ue()? + 1;
|
||||
let num_ref_idx_l0_default = r.read_ue_plus(1)?;
|
||||
let num_ref_idx_l1_default = r.read_ue_plus(1)?;
|
||||
ensure!(num_ref_idx_l0_default <= 15 && num_ref_idx_l1_default <= 15, "num_ref_idx_default_active out of range");
|
||||
let init_qp = 26 + r.read_se()?;
|
||||
let constrained_intra_pred = r.read_flag()?;
|
||||
@@ -742,8 +745,8 @@ impl Pps {
|
||||
let (mut column_widths, mut row_heights) = (Vec::new(), Vec::new());
|
||||
let mut loop_filter_across_tiles = true;
|
||||
if tiles_enabled {
|
||||
num_tile_columns = r.read_ue()? + 1;
|
||||
num_tile_rows = r.read_ue()? + 1;
|
||||
num_tile_columns = r.read_ue_plus(1)?;
|
||||
num_tile_rows = r.read_ue_plus(1)?;
|
||||
ensure!(num_tile_columns <= 64 && num_tile_rows <= 64, "too many tiles");
|
||||
uniform_spacing = r.read_flag()?;
|
||||
if !uniform_spacing {
|
||||
@@ -770,7 +773,7 @@ impl Pps {
|
||||
}
|
||||
let scaling_list = if r.read_flag()? { Some(ScalingList::parse(&mut r)?) } else { None };
|
||||
let lists_modification_present = r.read_flag()?;
|
||||
let log2_parallel_merge_level = r.read_ue()? + 2;
|
||||
let log2_parallel_merge_level = r.read_ue_plus(2)?;
|
||||
ensure!(log2_parallel_merge_level <= 6, "log2_parallel_merge_level out of range");
|
||||
let slice_header_extension_present = r.read_flag()?;
|
||||
let mut range_extension = false;
|
||||
@@ -933,3 +936,67 @@ impl Layout {
|
||||
self.tile_id[self.rs_to_ts[rs as usize] as usize]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_bitstream::BitWriter;
|
||||
|
||||
/// A 64x64 Main-profile SPS (CTB 64, no VUI) with a chosen conformance window and CTB-size delta.
|
||||
fn sps_bits(conf_win: Option<[u32; 4]>, log2_diff_cb: u32) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(0, 4); // vps id
|
||||
w.write_bits(0, 3); // max_sub_layers_minus1
|
||||
w.write_bit(true); // temporal_id_nesting
|
||||
w.write_bits(0, 2); // profile_space
|
||||
w.write_bit(false); // tier
|
||||
w.write_bits(1, 5); // profile_idc
|
||||
w.write_bits(0x6000_0000, 32); // compatibility
|
||||
w.write_bits(0b1001, 4); // progressive, interlaced, non_packed, frame_only
|
||||
w.write_bits(0, 32);
|
||||
w.write_bits(0, 12); // 43 constraint bits + 1 reserved
|
||||
w.write_bits(93, 8); // level_idc
|
||||
[0, 1, 64, 64].iter().for_each(|&v| w.write_ue(v)); // sps id, chroma 4:2:0, width, height
|
||||
w.write_bit(conf_win.is_some());
|
||||
conf_win.iter().flatten().for_each(|&v| w.write_ue(v));
|
||||
[0, 0, 4].iter().for_each(|&v| w.write_ue(v)); // bit depths, log2_max_poc_lsb_minus4
|
||||
w.write_bit(true); // sub_layer_ordering_info_present
|
||||
[0, 0, 0].iter().for_each(|&v| w.write_ue(v)); // dpb, reorder, latency
|
||||
w.write_ue(0); // log2_min_cb_minus3
|
||||
w.write_ue(log2_diff_cb);
|
||||
w.write_ue(0); // log2_min_tb_minus2
|
||||
w.write_ue(3); // log2_diff_max_min_tb
|
||||
w.write_ue(1); // max_transform_hierarchy_depth_inter
|
||||
w.write_ue(1); // ... intra
|
||||
[false; 4].iter().for_each(|&b| w.write_bit(b)); // scaling list, amp, sao, pcm
|
||||
w.write_ue(0); // num_short_term_ref_pic_sets
|
||||
[false; 5].iter().for_each(|&b| w.write_bit(b)); // long term, temporal mvp, strong intra, vui, extension
|
||||
w.rbsp_trailing();
|
||||
w.finish()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sps_values_are_errors_not_overflows() {
|
||||
assert!(Sps::parse(&sps_bits(None, 3)).is_ok());
|
||||
// log2_diff_max_min_luma_coding_block_size = u32::MAX: `log2_min_cb + ue` used to overflow.
|
||||
assert!(Sps::parse(&sps_bits(None, u32::MAX)).is_err());
|
||||
// Conformance window offsets whose product with SubWidthC / SubHeightC exceeds u32.
|
||||
let sps = Sps::parse(&sps_bits(Some([u32::MAX; 4]), 3)).unwrap();
|
||||
let (x, y, w, h) = sps.crop_rect();
|
||||
assert!(x < 64 && y < 64 && w >= 1 && h >= 1, "{:?}", (x, y, w, h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_st_rps_deltas_are_errors_not_overflows() {
|
||||
for ue in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(1); // num_negative_pics
|
||||
w.write_ue(0); // num_positive_pics
|
||||
w.write_ue(ue); // delta_poc_s0_minus1
|
||||
w.write_bit(true);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
assert!(StRps::parse(&mut BitReader::new(&b), 0, &[], 1).is_err(), "ue {ue:#x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,9 +293,9 @@ impl SliceHeader {
|
||||
if sh.slice_type != SliceType::I {
|
||||
sh.num_ref_idx = [pps.num_ref_idx_l0_default, if sh.is_b() { pps.num_ref_idx_l1_default } else { 0 }];
|
||||
if r.read_flag()? {
|
||||
sh.num_ref_idx[0] = r.read_ue()? + 1;
|
||||
sh.num_ref_idx[0] = r.read_ue_plus(1)?;
|
||||
if sh.is_b() {
|
||||
sh.num_ref_idx[1] = r.read_ue()? + 1;
|
||||
sh.num_ref_idx[1] = r.read_ue_plus(1)?;
|
||||
}
|
||||
}
|
||||
ensure!(sh.num_ref_idx[0] <= 15 && sh.num_ref_idx[1] <= 15, "num_ref_idx_active out of range");
|
||||
@@ -365,7 +365,7 @@ impl SliceHeader {
|
||||
let n = r.read_ue()?;
|
||||
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
|
||||
if n > 0 {
|
||||
let len = r.read_ue()? + 1;
|
||||
let len = r.read_ue_plus(1)?;
|
||||
ensure!(len <= 32, "offset_len_minus1 out of range");
|
||||
for _ in 0..n {
|
||||
sh.entry_points.push(r.read_bits(len)? + 1);
|
||||
|
||||
@@ -89,3 +89,31 @@ fn errors_are_reported_not_panics() {
|
||||
let r = call(&mut s, 3, "tools/call", json!({"name": "add_shape", "arguments": {"preset": "rect"}}));
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_tool_lists_pdf_and_its_options() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
let tools = call(&mut s, 1, "tools/list", json!({}));
|
||||
let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone();
|
||||
assert!(export["description"].as_str().unwrap().contains("pdf"), "{export}");
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
let formats = props["format"]["enum"].as_array().unwrap();
|
||||
for f in ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"] {
|
||||
assert!(formats.iter().any(|x| x == f), "format {f}: {export}");
|
||||
}
|
||||
assert_eq!(props["layout"]["enum"], json!(["slides", "notes", "handouts"]));
|
||||
assert_eq!(props["perPage"]["enum"], json!([1, 2, 3, 4, 6, 9]));
|
||||
for p in ["dpi", "slides", "includeHidden", "textLayer", "frame"] {
|
||||
assert!(props.get(p).is_some(), "{p}: {export}");
|
||||
}
|
||||
// What the schema describes is accepted.
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-mcp-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
tool(&mut s, "new_presentation", json!({}));
|
||||
for layout in ["slides", "notes", "handouts"] {
|
||||
let path = dir.join(format!("{layout}.pdf"));
|
||||
tool(&mut s, "export", json!({"path": path.to_string_lossy(), "format": "pdf", "layout": layout, "perPage": 3}));
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"), "{layout}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
+15
-2
@@ -115,9 +115,22 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, outline text.",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, or pdf (slides, notes pages or handouts).",
|
||||
obj(
|
||||
json!({"path": string("Output path"), "format": string("png|jpeg|pptx|deckcraft|outline"), "slide": int("Slide index for images"), "all": boolean("All slides (images)"), "scale": num("Pixels per point for images (default 2)")}),
|
||||
json!({
|
||||
"path": string("Output path"),
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"], "description": "Output format (default: from the path's extension)"},
|
||||
"slide": int("Slide index for images"),
|
||||
"all": boolean("All slides (images)"),
|
||||
"scale": num("Pixels per point for images (default 2)"),
|
||||
"layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"},
|
||||
"perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"},
|
||||
"dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"),
|
||||
"slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF: slide indices to export (default all)"},
|
||||
"includeHidden": boolean("PDF: include hidden slides (default false)"),
|
||||
"textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"),
|
||||
"frame": boolean("PDF notes/handouts: thin frame around each slide (default true)")
|
||||
}),
|
||||
&["path"],
|
||||
),
|
||||
false,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "deckcraft-metafile"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DeckCraft metafiles (ported from WordCraft): parses Windows WMF and EMF records into a neutral vector model (paths and bitmaps)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Bounds-checked little-endian access to hostile input. Every read returns `None` past the end, so
|
||||
//! a truncated or lying size field stops the parse instead of panicking.
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Bytes<'a>(pub(crate) &'a [u8]);
|
||||
|
||||
impl<'a> Bytes<'a> {
|
||||
pub fn slice(&self, off: usize, len: usize) -> Option<&'a [u8]> {
|
||||
self.0.get(off..off.checked_add(len)?)
|
||||
}
|
||||
|
||||
pub fn tail(&self, off: usize) -> Option<Bytes<'a>> {
|
||||
self.0.get(off..).map(Bytes)
|
||||
}
|
||||
|
||||
fn arr<const N: usize>(&self, off: usize) -> Option<[u8; N]> {
|
||||
self.slice(off, N)?.try_into().ok()
|
||||
}
|
||||
|
||||
pub fn u16(&self, off: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
pub fn i16(&self, off: usize) -> Option<i16> {
|
||||
Some(i16::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
pub fn u32(&self, off: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
pub fn i32(&self, off: usize) -> Option<i32> {
|
||||
Some(i32::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
pub fn f32(&self, off: usize) -> Option<f32> {
|
||||
Some(f32::from_le_bytes(self.arr(off)?))
|
||||
}
|
||||
|
||||
/// `n` consecutive (x, y) pairs of 16- or 32-bit signed integers starting at `off`.
|
||||
pub fn points(&self, off: usize, n: usize, wide: bool) -> Option<Vec<(f64, f64)>> {
|
||||
let sz = if wide { 8 } else { 4 };
|
||||
let raw = self.slice(off, n.checked_mul(sz)?)?;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for c in raw.chunks_exact(sz) {
|
||||
let p = if wide {
|
||||
let x = i32::from_le_bytes(c.get(0..4)?.try_into().ok()?);
|
||||
let y = i32::from_le_bytes(c.get(4..8)?.try_into().ok()?);
|
||||
(f64::from(x), f64::from(y))
|
||||
} else {
|
||||
let x = i16::from_le_bytes(c.get(0..2)?.try_into().ok()?);
|
||||
let y = i16::from_le_bytes(c.get(2..4)?.try_into().ok()?);
|
||||
(f64::from(x), f64::from(y))
|
||||
};
|
||||
out.push(p);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,893 @@
|
||||
//! Drawing state shared by the WMF and EMF readers: window/viewport mapping and the EMF world
|
||||
//! transform, pen and brush selection through an object table, the DC save stack and path brackets.
|
||||
//! Output is device-space items; `lib.rs` maps them into the picture frame.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use crate::dib::Rgba;
|
||||
use crate::geom::{Op, P};
|
||||
use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, TA_UPDATECP, Text, align};
|
||||
use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK};
|
||||
|
||||
pub(crate) const MM_TEXT: u32 = 1;
|
||||
const MM_ISOTROPIC: u32 = 7;
|
||||
const MM_ANISOTROPIC: u32 = 8;
|
||||
const STOCK: u32 = 0x8000_0000;
|
||||
pub(crate) const PS_NULL: u32 = 5;
|
||||
/// Pen style bits; the rest of the style word holds end-cap, join and type flags.
|
||||
pub(crate) const PS_STYLE_MASK: u32 = 0x0F;
|
||||
const BS_SOLID: u32 = 0;
|
||||
pub(crate) const BS_NULL: u32 = 1;
|
||||
pub(crate) const BS_HATCHED: u32 = 2;
|
||||
/// BS_PATTERN, BS_DIBPATTERN and BS_DIBPATTERNPT: the brush paints a bitmap tile.
|
||||
const BS_PATTERN: u32 = 3;
|
||||
const BS_DIBPATTERN: u32 = 5;
|
||||
const BS_DIBPATTERNPT: u32 = 6;
|
||||
const WINDING: u32 = 2;
|
||||
/// SETROP2 mix modes. R2_NOP leaves the destination alone: outlines and fills draw nothing.
|
||||
/// R2_XORPEN and R2_MASKPEN draw the classic see-through shadow (XOR a colour, AND a pattern, XOR
|
||||
/// the colour again); the others draw as R2_COPYPEN.
|
||||
const R2_XORPEN: u32 = 7;
|
||||
const R2_MASKPEN: u32 = 9;
|
||||
const R2_NOP: u32 = 11;
|
||||
const R2_COPYPEN: u32 = 13;
|
||||
/// SETBKMODE OPAQUE: text cells and hatch gaps are filled with the background colour.
|
||||
const OPAQUE: u32 = 2;
|
||||
const PATCOPY: u32 = 0x00F0_0021;
|
||||
const BLACKNESS: u32 = 0x0000_0042;
|
||||
const WHITENESS: u32 = 0x00FF_0062;
|
||||
/// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size.
|
||||
const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4;
|
||||
|
||||
/// Affine transform `[m11, m12, m21, m22, dx, dy]` in EMF XFORM order: `x' = x·m11 + y·m21 + dx`.
|
||||
pub(crate) type Xform = [f64; 6];
|
||||
const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
|
||||
/// `a` then `b` in EMF row-vector convention: the matrix product `a × b`.
|
||||
pub(crate) fn mul(a: Xform, b: Xform) -> Xform {
|
||||
[
|
||||
a[0] * b[0] + a[1] * b[2],
|
||||
a[0] * b[1] + a[1] * b[3],
|
||||
a[2] * b[0] + a[3] * b[2],
|
||||
a[2] * b[1] + a[3] * b[3],
|
||||
a[4] * b[0] + a[5] * b[2] + b[4],
|
||||
a[4] * b[1] + a[5] * b[3] + b[5],
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(crate) struct Pen {
|
||||
pub style: u32,
|
||||
/// Logical width; 0 means a cosmetic one-pixel hairline.
|
||||
pub width: f64,
|
||||
pub color: [u8; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub(crate) struct Brush {
|
||||
pub style: u32,
|
||||
pub color: [u8; 4],
|
||||
/// HS_* hatch style of a BS_HATCHED brush.
|
||||
pub hatch: u32,
|
||||
/// The tile of a pattern brush.
|
||||
pub pattern: Option<Arc<Pattern>>,
|
||||
}
|
||||
|
||||
impl Brush {
|
||||
pub fn new(style: u32, color: [u8; 4]) -> Self {
|
||||
Brush { style, color, hatch: 0, pattern: None }
|
||||
}
|
||||
|
||||
/// A pattern brush painting `img`. A monochrome brush paints its dark pixels in the text
|
||||
/// colour and its light ones in the background colour. None for an empty or oversized tile.
|
||||
pub fn pattern(img: Rgba, mono: bool) -> Option<Self> {
|
||||
if img.w == 0 || img.h == 0 || img.w > MAX_TILE_SIDE || img.h > MAX_TILE_SIDE || img.px.len() != img.w * img.h * 4 {
|
||||
return None;
|
||||
}
|
||||
let pattern = Arc::new(Pattern { w: img.w, h: img.h, px: img.px, mono, plain: OnceLock::new() });
|
||||
Some(Brush { style: BS_DIBPATTERNPT, color: [0, 0, 0, 255], hatch: 0, pattern: Some(pattern) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern brush's tile, top-down RGBA.
|
||||
#[derive(PartialEq, Debug)]
|
||||
pub(crate) struct Pattern {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub px: Vec<u8>,
|
||||
pub mono: bool,
|
||||
/// The tile as drawn when nothing recolours it, shared by every fill with this brush.
|
||||
pub plain: OnceLock<Arc<RawTile>>,
|
||||
}
|
||||
|
||||
/// Largest pattern tile side kept (GDI brushes are typically 8×8).
|
||||
pub(crate) const MAX_TILE_SIDE: usize = 256;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Obj {
|
||||
Pen(Pen),
|
||||
Brush(Brush),
|
||||
Font(Arc<LogFont>),
|
||||
/// Palettes and regions: they take a table slot but draw nothing.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A font object: the neutral [`Font`] and its escapement (tenths of a degree, counter-clockwise),
|
||||
/// which the text transform takes.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub(crate) struct LogFont {
|
||||
pub font: Font,
|
||||
pub escapement: f64,
|
||||
}
|
||||
|
||||
/// A brush tile in device units: `cell` device units per tile pixel, laid from the device origin.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) struct RawTile {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub px: Vec<u8>,
|
||||
pub cell: f64,
|
||||
}
|
||||
|
||||
/// What a fill paints.
|
||||
enum Paint {
|
||||
Solid([u8; 4]),
|
||||
Tile(Arc<RawTile>),
|
||||
}
|
||||
|
||||
/// Converts a COLORREF (0x00BBGGRR) to opaque RGBA.
|
||||
pub(crate) fn colorref(c: u32) -> [u8; 4] {
|
||||
[(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8, ((c >> 16) & 0xFF) as u8, 255]
|
||||
}
|
||||
|
||||
/// Millimetres per logical unit of the metric map modes (LOMETRIC, HIMETRIC, LOENGLISH, HIENGLISH, TWIPS).
|
||||
fn metric_mm(mode: u32) -> Option<f64> {
|
||||
match mode {
|
||||
2 => Some(0.1),
|
||||
3 => Some(0.01),
|
||||
4 => Some(25.4 / 100.0),
|
||||
5 => Some(25.4 / 1000.0),
|
||||
6 => Some(25.4 / 1440.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Mapping {
|
||||
wo: P,
|
||||
we: P,
|
||||
vo: P,
|
||||
ve: P,
|
||||
mode: u32,
|
||||
world: Xform,
|
||||
/// Device pixels per millimetre on each axis (EMF: from szlDevice and szlMillimeters).
|
||||
px_mm: P,
|
||||
}
|
||||
|
||||
impl Mapping {
|
||||
fn identity() -> Self {
|
||||
Mapping {
|
||||
wo: (0.0, 0.0),
|
||||
we: (1.0, 1.0),
|
||||
vo: (0.0, 0.0),
|
||||
ve: (1.0, 1.0),
|
||||
mode: MM_TEXT,
|
||||
world: IDENTITY,
|
||||
px_mm: (DEFAULT_PX_PER_MM, DEFAULT_PX_PER_MM),
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical point to device point: world transform, then window to viewport.
|
||||
pub fn device(&self, p: P) -> P {
|
||||
let w = self.world;
|
||||
let (x, y) = (p.0 * w[0] + p.1 * w[2] + w[4], p.0 * w[1] + p.1 * w[3] + w[5]);
|
||||
let (sx, sy) = self.ratios();
|
||||
((x - self.wo.0) * sx + self.vo.0, (y - self.wo.1) * sy + self.vo.1)
|
||||
}
|
||||
|
||||
/// Window-to-viewport scale per axis. MM_TEXT fixes the scale at 1; the metric modes use a fixed
|
||||
/// scale with y up; MM_ISOTROPIC uses one scale on both axes (the smaller magnitude, each axis's
|
||||
/// sign kept); anisotropic and unknown modes use the extents as given.
|
||||
fn ratios(&self) -> (f64, f64) {
|
||||
if self.mode == MM_TEXT {
|
||||
return (1.0, 1.0);
|
||||
}
|
||||
if let Some(mm) = metric_mm(self.mode) {
|
||||
return (mm * self.px_mm.0, -mm * self.px_mm.1);
|
||||
}
|
||||
let (rx, ry) = (ratio(self.ve.0, self.we.0), ratio(self.ve.1, self.we.1));
|
||||
if self.mode == MM_ISOTROPIC {
|
||||
let s = rx.abs().min(ry.abs());
|
||||
return (if rx < 0.0 { -s } else { s }, if ry < 0.0 { -s } else { s });
|
||||
}
|
||||
(rx, ry)
|
||||
}
|
||||
|
||||
/// Approximate device length per logical length, for pen widths.
|
||||
fn scale(&self) -> f64 {
|
||||
let w = self.world;
|
||||
let det = (w[0] * w[3] - w[1] * w[2]).abs().sqrt();
|
||||
let (sx, sy) = self.ratios();
|
||||
det * (sx.abs() + sy.abs()) / 2.0
|
||||
}
|
||||
|
||||
/// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes).
|
||||
pub fn uses_extents(&self) -> bool {
|
||||
matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC)
|
||||
}
|
||||
|
||||
pub fn window_ext(&self) -> P {
|
||||
self.we
|
||||
}
|
||||
|
||||
pub fn viewport_ext(&self) -> P {
|
||||
self.ve
|
||||
}
|
||||
|
||||
/// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame
|
||||
/// of a plain WMF.
|
||||
pub fn window_rect(&self) -> [f64; 4] {
|
||||
let a = self.device(self.wo);
|
||||
let b = self.device((self.wo.0 + self.we.0, self.wo.1 + self.we.1));
|
||||
[a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio(num: f64, den: f64) -> f64 {
|
||||
if den == 0.0 { 1.0 } else { num / den }
|
||||
}
|
||||
|
||||
/// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them.
|
||||
pub(crate) enum Raw {
|
||||
Path {
|
||||
ops: Vec<Op>,
|
||||
fill: Option<[u8; 4]>,
|
||||
stroke: Option<([u8; 4], f64)>,
|
||||
even_odd: bool,
|
||||
},
|
||||
/// A path filled with a repeating tile.
|
||||
Pattern {
|
||||
ops: Vec<Op>,
|
||||
even_odd: bool,
|
||||
tile: Arc<RawTile>,
|
||||
},
|
||||
Bitmap {
|
||||
rect: [f64; 4],
|
||||
img: Rgba,
|
||||
},
|
||||
/// Text whose transform and clip are in device units.
|
||||
Text(Box<Text>),
|
||||
}
|
||||
|
||||
/// Text drawing state: colours, background mode, alignment and the selected font.
|
||||
#[derive(Clone)]
|
||||
struct TextState {
|
||||
color: [u8; 4],
|
||||
bk_color: [u8; 4],
|
||||
bk_opaque: bool,
|
||||
align: u32,
|
||||
font: Option<Arc<LogFont>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Saved {
|
||||
map: Mapping,
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
even_odd: bool,
|
||||
rop2: u32,
|
||||
text: TextState,
|
||||
}
|
||||
|
||||
pub(crate) struct Canvas {
|
||||
pub items: Vec<Raw>,
|
||||
pub map: Mapping,
|
||||
/// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use.
|
||||
pub window_set: bool,
|
||||
/// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]).
|
||||
pub viewport_set: bool,
|
||||
saved: Vec<Saved>,
|
||||
/// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`].
|
||||
used: usize,
|
||||
objects: Vec<Option<Obj>>,
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
even_odd: bool,
|
||||
/// SETROP2 mix mode.
|
||||
rop2: u32,
|
||||
/// The colour of the last fill drawn with R2_XORPEN, for the see-through shadow idiom.
|
||||
xor_color: Option<[u8; 4]>,
|
||||
text: TextState,
|
||||
/// Device units per pattern-brush pixel: brushes are laid out in 96-dpi pixels.
|
||||
pub pattern_px: f64,
|
||||
pos: P,
|
||||
in_path: bool,
|
||||
path: Vec<Op>,
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
pub fn new() -> Self {
|
||||
Canvas {
|
||||
items: Vec::new(),
|
||||
map: Mapping::identity(),
|
||||
window_set: false,
|
||||
viewport_set: false,
|
||||
saved: Vec::new(),
|
||||
used: 0,
|
||||
objects: Vec::new(),
|
||||
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
|
||||
brush: Brush::new(BS_SOLID, [255, 255, 255, 255]),
|
||||
even_odd: true,
|
||||
rop2: R2_COPYPEN,
|
||||
xor_color: None,
|
||||
text: TextState { color: [0, 0, 0, 255], bk_color: [255, 255, 255, 255], bk_opaque: true, align: 0, font: None },
|
||||
pattern_px: 1.0,
|
||||
pos: (0.0, 0.0),
|
||||
in_path: false,
|
||||
path: Vec::new(),
|
||||
full: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True once the item cap is hit; the readers stop there and keep what they have.
|
||||
pub fn full(&self) -> bool {
|
||||
self.full
|
||||
}
|
||||
|
||||
fn push(&mut self, raw: Raw) {
|
||||
let cost = match &raw {
|
||||
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
|
||||
// A tile shared with its brush is counted once (by the brush); one made for this fill
|
||||
// counts here.
|
||||
Raw::Pattern { ops, tile, .. } => {
|
||||
let own = if Arc::strong_count(tile) == 1 { tile.px.len() } else { 0 };
|
||||
ops.len() * std::mem::size_of::<Op>() + 64 + own
|
||||
}
|
||||
Raw::Bitmap { img, .. } => img.px.len(),
|
||||
Raw::Text(t) => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
|
||||
};
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
} else {
|
||||
self.used += cost;
|
||||
self.items.push(raw);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_window_org(&mut self, p: P) {
|
||||
self.map.wo = p;
|
||||
}
|
||||
|
||||
pub fn set_window_ext(&mut self, e: P) {
|
||||
self.map.we = e;
|
||||
self.window_set = true;
|
||||
}
|
||||
|
||||
pub fn set_viewport_org(&mut self, p: P) {
|
||||
self.map.vo = p;
|
||||
}
|
||||
|
||||
pub fn set_viewport_ext(&mut self, e: P) {
|
||||
self.map.ve = e;
|
||||
self.viewport_set = true;
|
||||
}
|
||||
|
||||
/// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic,
|
||||
/// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's
|
||||
/// own window records then replace the window; the viewport stays unless the file sets one.
|
||||
/// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport
|
||||
/// origin stay the file's.
|
||||
pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) {
|
||||
if let Some([x, y, w, h]) = window {
|
||||
self.map.mode = MM_ANISOTROPIC;
|
||||
self.map.wo = (x, y);
|
||||
self.map.we = (w, h);
|
||||
self.map.vo = (0.0, 0.0);
|
||||
}
|
||||
self.map.ve = size;
|
||||
}
|
||||
|
||||
pub fn set_map_mode(&mut self, mode: u32) {
|
||||
self.map.mode = mode;
|
||||
}
|
||||
|
||||
/// Device pixels per millimetre (x, y) for the metric map modes. Ignored unless both are positive.
|
||||
pub fn set_device_mm(&mut self, px_mm: P) {
|
||||
if px_mm.0.is_finite() && px_mm.1.is_finite() && px_mm.0 > 0.0 && px_mm.1 > 0.0 {
|
||||
self.map.px_mm = px_mm;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_polyfill(&mut self, mode: u32) {
|
||||
self.even_odd = mode != WINDING;
|
||||
}
|
||||
|
||||
/// SETROP2. R2_NOP draws nothing; R2_XORPEN and R2_MASKPEN are read as the see-through shadow
|
||||
/// idiom (see [`Canvas::fill_paint`]); the other mix modes draw as copy.
|
||||
pub fn set_rop2(&mut self, mode: u32) {
|
||||
self.rop2 = mode;
|
||||
}
|
||||
|
||||
pub fn set_text_color(&mut self, c: u32) {
|
||||
self.text.color = colorref(c);
|
||||
}
|
||||
|
||||
pub fn set_bk_color(&mut self, c: u32) {
|
||||
self.text.bk_color = colorref(c);
|
||||
}
|
||||
|
||||
pub fn set_bk_mode(&mut self, mode: u32) {
|
||||
self.text.bk_opaque = mode == OPAQUE;
|
||||
}
|
||||
|
||||
pub fn set_text_align(&mut self, ta: u32) {
|
||||
self.text.align = ta;
|
||||
}
|
||||
|
||||
/// The character set of the selected font (ANSI without one).
|
||||
pub fn font_charset(&self) -> u8 {
|
||||
self.text.font.as_ref().map_or(0, |f| f.font.charset)
|
||||
}
|
||||
|
||||
/// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set).
|
||||
/// An unknown mode leaves the world transform as it was.
|
||||
pub fn modify_world(&mut self, x: Xform, mode: u32) {
|
||||
match mode {
|
||||
1 => self.map.world = IDENTITY,
|
||||
2 => self.map.world = mul(x, self.map.world),
|
||||
3 => self.map.world = mul(self.map.world, x),
|
||||
4 => self.map.world = x,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores
|
||||
/// could no longer match the file.
|
||||
pub fn save_dc(&mut self) {
|
||||
if self.saved.len() < MAX_STACK {
|
||||
self.saved.push(Saved {
|
||||
map: self.map,
|
||||
pen: self.pen,
|
||||
brush: self.brush.clone(),
|
||||
even_odd: self.even_odd,
|
||||
rop2: self.rop2,
|
||||
text: self.text.clone(),
|
||||
});
|
||||
} else {
|
||||
self.full = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative `n` pops `-n` levels; positive `n` restores absolute level `n`.
|
||||
pub fn restore_dc(&mut self, n: i32) {
|
||||
let idx = if n < 0 { self.saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) };
|
||||
let Some(idx) = idx else { return };
|
||||
let Some(s) = self.saved.get(idx).cloned() else { return };
|
||||
self.saved.truncate(idx);
|
||||
self.map = s.map;
|
||||
self.pen = s.pen;
|
||||
self.brush = s.brush;
|
||||
self.even_odd = s.even_odd;
|
||||
self.rop2 = s.rop2;
|
||||
self.text = s.text;
|
||||
}
|
||||
|
||||
/// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table).
|
||||
pub fn add_object(&mut self, slot: Option<usize>, obj: Obj) {
|
||||
// A pattern brush's pixels count against the byte budget like drawn items.
|
||||
if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj {
|
||||
let cost = p.px.len();
|
||||
if self.used.saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
self.used += cost;
|
||||
}
|
||||
match slot {
|
||||
Some(i) if i < MAX_OBJECTS => {
|
||||
if self.objects.len() <= i {
|
||||
self.objects.resize(i + 1, None);
|
||||
}
|
||||
if let Some(s) = self.objects.get_mut(i) {
|
||||
*s = Some(obj);
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => match self.objects.iter().position(Option::is_none) {
|
||||
Some(i) => {
|
||||
if let Some(s) = self.objects.get_mut(i) {
|
||||
*s = Some(obj);
|
||||
}
|
||||
}
|
||||
None if self.objects.len() < MAX_OBJECTS => self.objects.push(Some(obj)),
|
||||
None => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_object(&mut self, i: u32) {
|
||||
if let Some(s) = self.objects.get_mut(i as usize) {
|
||||
*s = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects an object by index; indices with the high bit set are stock objects.
|
||||
pub fn select(&mut self, i: u32) {
|
||||
if i & STOCK != 0 {
|
||||
return self.select_stock(i & !STOCK);
|
||||
}
|
||||
match self.objects.get(i as usize).cloned().flatten() {
|
||||
Some(Obj::Pen(p)) => self.pen = p,
|
||||
Some(Obj::Brush(b)) => self.brush = b,
|
||||
Some(Obj::Font(f)) => self.text.font = Some(f),
|
||||
Some(Obj::Other) | None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_stock(&mut self, i: u32) {
|
||||
let white = [255, 255, 255, 255];
|
||||
let black = [0, 0, 0, 255];
|
||||
let gray = |v: u8| Brush::new(BS_SOLID, [v, v, v, 255]);
|
||||
match i {
|
||||
0 | 18 => self.brush = Brush::new(BS_SOLID, white),
|
||||
1 => self.brush = gray(192),
|
||||
2 => self.brush = gray(128),
|
||||
3 => self.brush = gray(64),
|
||||
4 => self.brush = Brush::new(BS_SOLID, black),
|
||||
5 => self.brush = Brush::new(BS_NULL, black),
|
||||
6 => self.pen = Pen { style: 0, width: 0.0, color: white },
|
||||
7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black },
|
||||
8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black },
|
||||
// The stock fonts (OEM_FIXED_FONT … DEFAULT_GUI_FONT): drawn in the default face.
|
||||
10..=17 => self.text.font = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_op(&self, op: Op) -> Op {
|
||||
match op {
|
||||
Op::M(p) => Op::M(self.map.device(p)),
|
||||
Op::L(p) => Op::L(self.map.device(p)),
|
||||
Op::C(a, b, c) => Op::C(self.map.device(a), self.map.device(b), self.map.device(c)),
|
||||
Op::Z => Op::Z,
|
||||
}
|
||||
}
|
||||
|
||||
/// The current brush's paint. Hatch and monochrome brushes take the background colour (when
|
||||
/// the background mode is opaque) and the text colour. Under R2_MASKPEN (destination AND
|
||||
/// brush) a tile's white pixels keep what is below, and its black ones take the colour of the
|
||||
/// XOR fill before it: XOR, AND, XOR again leaves that colour where the tile is black.
|
||||
fn fill_paint(&self) -> Option<Paint> {
|
||||
let b = &self.brush;
|
||||
match b.style {
|
||||
BS_SOLID => Some(Paint::Solid(b.color)),
|
||||
BS_HATCHED => {
|
||||
let bg = self.text.bk_opaque.then_some(self.text.bk_color);
|
||||
let px = hatch_tile(b.hatch, b.color, bg)?;
|
||||
Some(Paint::Tile(Arc::new(RawTile { w: 8, h: 8, px, cell: self.pattern_px })))
|
||||
}
|
||||
BS_PATTERN | BS_DIBPATTERN | BS_DIBPATTERNPT => {
|
||||
let p = b.pattern.as_ref()?;
|
||||
if !p.mono && self.rop2 != R2_MASKPEN {
|
||||
let cell = self.pattern_px;
|
||||
return Some(Paint::Tile(p.plain.get_or_init(|| Arc::new(RawTile { w: p.w, h: p.h, px: p.px.clone(), cell })).clone()));
|
||||
}
|
||||
let mut px = p.px.clone();
|
||||
for c in px.as_chunks_mut::<4>().0 {
|
||||
if p.mono {
|
||||
let light = u16::from(c[0]) + u16::from(c[1]) + u16::from(c[2]) >= 384;
|
||||
let v = if light { self.text.bk_color } else { self.text.color };
|
||||
*c = [v[0], v[1], v[2], 255];
|
||||
}
|
||||
if self.rop2 == R2_MASKPEN {
|
||||
if c[..3] == [255, 255, 255] {
|
||||
c[3] = 0;
|
||||
} else if c[..3] == [0, 0, 0]
|
||||
&& let Some(x) = self.xor_color
|
||||
{
|
||||
*c = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Paint::Tile(Arc::new(RawTile { w: p.w, h: p.h, px, cell: self.pattern_px })))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes device ops filled with `fill` and stroked with `stroke`: a tile fill and its outline
|
||||
/// are two items.
|
||||
fn push_shape(&mut self, ops: Vec<Op>, fill: Option<Paint>, stroke: Option<([u8; 4], f64)>) {
|
||||
let even_odd = self.even_odd;
|
||||
match fill {
|
||||
Some(Paint::Tile(tile)) => {
|
||||
if stroke.is_some() {
|
||||
self.push(Raw::Pattern { ops: ops.clone(), even_odd, tile });
|
||||
self.push(Raw::Path { ops, fill: None, stroke, even_odd });
|
||||
} else {
|
||||
self.push(Raw::Pattern { ops, even_odd, tile });
|
||||
}
|
||||
}
|
||||
Some(Paint::Solid(c)) => self.push(Raw::Path { ops, fill: Some(c), stroke, even_odd }),
|
||||
None if stroke.is_some() => self.push(Raw::Path { ops, fill: None, stroke, even_odd }),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the mix mode suppresses drawing: R2_NOP draws nothing, and R2_XORPEN shapes only
|
||||
/// remember their fill colour (an XOR pair cancels; the see-through shadow idiom draws its
|
||||
/// pattern in that colour).
|
||||
fn mix_skips(&mut self, fill: bool) -> bool {
|
||||
match self.rop2 {
|
||||
R2_NOP => true,
|
||||
R2_XORPEN => {
|
||||
if fill && self.brush.style == BS_SOLID {
|
||||
self.xor_color = Some(self.brush.color);
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_pen(&self) -> Option<([u8; 4], f64)> {
|
||||
if (self.pen.style & PS_STYLE_MASK) == PS_NULL {
|
||||
return None;
|
||||
}
|
||||
// A cosmetic pen (width 0) stays 0: the renderers draw that as a hairline.
|
||||
let w = if self.pen.width == 0.0 { 0.0 } else { self.pen.width.abs() * self.map.scale() };
|
||||
Some((self.pen.color, w))
|
||||
}
|
||||
|
||||
fn push_path_ops(&mut self, ops: impl IntoIterator<Item = Op>) {
|
||||
for op in ops {
|
||||
if self.path.len() >= MAX_PATH_OPS {
|
||||
return;
|
||||
}
|
||||
self.path.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws logical-space ops with the current pen (`stroke`) and brush (`fill`), or appends them to
|
||||
/// the open path bracket, where fill and stroke wait for FILLPATH/STROKEPATH.
|
||||
pub fn shape(&mut self, ops: &[Op], fill: bool, stroke: bool) {
|
||||
let dev: Vec<Op> = ops.iter().map(|o| self.map_op(*o)).collect();
|
||||
if self.in_path {
|
||||
return self.push_path_ops(dev);
|
||||
}
|
||||
if self.mix_skips(fill) || dev.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_paint() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
self.push_shape(dev, fill, stroke);
|
||||
}
|
||||
|
||||
pub fn move_to(&mut self, p: P) {
|
||||
if self.in_path {
|
||||
self.push_path_ops([Op::M(self.map.device(p))]);
|
||||
}
|
||||
self.pos = p;
|
||||
}
|
||||
|
||||
/// LINETO from the current point, then moves the current point to `p`.
|
||||
pub fn line_to(&mut self, p: P) {
|
||||
self.line_chain(&[p], false, true);
|
||||
}
|
||||
|
||||
/// Lines from the current point through `pts` (POLYLINETO), or a polyline from the current point.
|
||||
pub fn line_chain(&mut self, pts: &[P], fill: bool, stroke: bool) {
|
||||
// Inside an open bracket the path already ends at the current point, so no MoveTo is needed.
|
||||
let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] };
|
||||
ops.extend(pts.iter().map(|p| Op::L(*p)));
|
||||
if let Some(last) = pts.last() {
|
||||
self.pos = *last;
|
||||
}
|
||||
self.shape(&ops, fill, stroke);
|
||||
}
|
||||
|
||||
/// POLYBEZIER: cubic segments that start a new figure at `from`. The current point is unchanged.
|
||||
pub fn bezier(&mut self, from: P, pts: &[P]) {
|
||||
let mut ops = vec![Op::M(from)];
|
||||
ops.extend(cubics(pts));
|
||||
self.shape(&ops, false, true);
|
||||
}
|
||||
|
||||
/// POLYBEZIERTO: cubic segments from the current point through groups of three control or end
|
||||
/// points; the current point moves to the last point. Inside an open bracket the path already
|
||||
/// ends at the current point, so no MoveTo is needed.
|
||||
pub fn bezier_to(&mut self, pts: &[P]) {
|
||||
let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] };
|
||||
ops.extend(cubics(pts));
|
||||
if let Some(last) = pts.last() {
|
||||
self.pos = *last;
|
||||
}
|
||||
self.shape(&ops, false, true);
|
||||
}
|
||||
|
||||
pub fn poly(&mut self, pts: &[P], close: bool, fill: bool, stroke: bool) {
|
||||
let Some(first) = pts.first() else { return };
|
||||
let mut ops = vec![Op::M(*first)];
|
||||
ops.extend(pts.iter().skip(1).map(|p| Op::L(*p)));
|
||||
if close {
|
||||
ops.push(Op::Z);
|
||||
}
|
||||
self.shape(&ops, fill, stroke);
|
||||
}
|
||||
|
||||
pub fn begin_path(&mut self) {
|
||||
self.in_path = true;
|
||||
self.path.clear();
|
||||
}
|
||||
|
||||
pub fn end_path(&mut self) {
|
||||
self.in_path = false;
|
||||
}
|
||||
|
||||
pub fn close_figure(&mut self) {
|
||||
if self.in_path {
|
||||
self.push_path_ops([Op::Z]);
|
||||
}
|
||||
}
|
||||
|
||||
/// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it.
|
||||
pub fn paint_path(&mut self, fill: bool, stroke: bool) {
|
||||
let ops = std::mem::take(&mut self.path);
|
||||
if self.mix_skips(fill) || ops.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_paint() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
self.push_shape(ops, fill, stroke);
|
||||
}
|
||||
|
||||
/// Fills a logical rectangle `[l, t, r, b]` with the current brush (PATBLT, PATCOPY blits).
|
||||
pub fn fill_brush_rect(&mut self, [l, t, r, b]: [f64; 4]) {
|
||||
if self.mix_skips(true) {
|
||||
return;
|
||||
}
|
||||
let fill = self.fill_paint();
|
||||
let dev: Vec<Op> = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect();
|
||||
self.push_shape(dev, fill, None);
|
||||
}
|
||||
|
||||
/// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS).
|
||||
pub fn fill_rect_with(&mut self, [l, t, r, b]: [f64; 4], color: [u8; 4]) {
|
||||
let dev: Vec<Op> = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect();
|
||||
self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true });
|
||||
}
|
||||
|
||||
/// Whether a raster operation draws without a source bitmap (PATCOPY, BLACKNESS, WHITENESS).
|
||||
pub fn sourceless(rop: u32) -> bool {
|
||||
matches!(rop, PATCOPY | BLACKNESS | WHITENESS)
|
||||
}
|
||||
|
||||
/// Fills a logical rectangle `[x, y, w, h]` for a source-less raster operation. PATCOPY uses the
|
||||
/// brush (when solid); the others fill black or white. Other operations are not handled here.
|
||||
pub fn fill_rop(&mut self, [x, y, w, h]: [f64; 4], rop: u32) {
|
||||
let rect = [x, y, x + w, y + h];
|
||||
match rop {
|
||||
PATCOPY => self.fill_brush_rect(rect),
|
||||
BLACKNESS => self.fill_rect_with(rect, [0, 0, 0, 255]),
|
||||
WHITENESS => self.fill_rect_with(rect, [255, 255, 255, 255]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `text` (one `dx` advance per character, or none) with the current font, colours and
|
||||
/// alignment. The reference point is `at`, or the current position under TA_UPDATECP (which
|
||||
/// then moves past the string). `rect` (logical `[l, t, r, b]`) is filled with the background
|
||||
/// colour under ETO_OPAQUE and clips the text under ETO_CLIPPED. Text ignores the mix mode, as
|
||||
/// GDI's does.
|
||||
pub fn text(&mut self, at: P, text: String, dx: Vec<f32>, options: u32, rect: Option<[f64; 4]>) {
|
||||
if let (true, Some(r)) = (options & ETO_OPAQUE != 0, rect) {
|
||||
self.fill_rect_with(r, self.text.bk_color);
|
||||
}
|
||||
let lf = self.text.font.as_deref().cloned().unwrap_or_default();
|
||||
let update = self.text.align & TA_UPDATECP != 0;
|
||||
let origin = if update { self.pos } else { at };
|
||||
let (h_align, v_align) = align(self.text.align);
|
||||
let o = self.map.device(origin);
|
||||
let ux = self.map.device((origin.0 + 1.0, origin.1));
|
||||
let uy = self.map.device((origin.0, origin.1 + 1.0));
|
||||
let (ux, uy) = ((ux.0 - o.0, ux.1 - o.1), (uy.0 - o.0, uy.1 - o.1));
|
||||
if update {
|
||||
// The current position moves along the baseline past the string (back past it when
|
||||
// right-aligned). Without a dx array the advance is estimated from the height.
|
||||
let adv: f64 = if dx.is_empty() {
|
||||
f64::from(lf.font.height.abs().max(1.0)) * 0.5 * text.chars().count() as f64
|
||||
} else {
|
||||
dx.iter().map(|d| f64::from(*d)).sum()
|
||||
};
|
||||
let adv = match h_align {
|
||||
crate::HAlign::Left => adv,
|
||||
crate::HAlign::Right => -adv,
|
||||
crate::HAlign::Center => 0.0,
|
||||
};
|
||||
let theta = (lf.escapement / 10.0).to_radians();
|
||||
let y_sign = if uy.1 < 0.0 { 1.0 } else { -1.0 };
|
||||
let p = (origin.0 + adv * theta.cos(), origin.1 + adv * theta.sin() * y_sign);
|
||||
if p.0.is_finite() && p.1.is_finite() {
|
||||
self.pos = p;
|
||||
}
|
||||
}
|
||||
let (sx, sy) = (ux.0.hypot(ux.1), uy.0.hypot(uy.1));
|
||||
let drawable = !text.is_empty() && [sx, sy, o.0, o.1].iter().all(|v| v.is_finite()) && sx > 0.0 && sy > 0.0;
|
||||
if !drawable {
|
||||
return;
|
||||
}
|
||||
// The baseline runs along the device direction of the logical x axis (rotated by a world
|
||||
// transform; a mirroring mapping does not mirror glyphs), turned counter-clockwise by the
|
||||
// escapement.
|
||||
let det = ux.0 * uy.1 - ux.1 * uy.0;
|
||||
let phi = if det > 0.0 { ux.1.atan2(ux.0) } else { ux.1.atan2(ux.0.abs()) };
|
||||
let a = phi - (lf.escapement / 10.0).to_radians();
|
||||
let (c, s) = (a.cos(), a.sin());
|
||||
let clip = match (options & ETO_CLIPPED != 0, rect) {
|
||||
(true, Some([l, t, r, b])) => {
|
||||
let (p, q) = (self.map.device((l, t)), self.map.device((r, b)));
|
||||
Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
self.push(Raw::Text(Box::new(Text {
|
||||
transform: [sx * c, sx * s, -sy * s, sy * c, o.0, o.1],
|
||||
text,
|
||||
dx,
|
||||
font: lf.font,
|
||||
color: self.text.color,
|
||||
background: self.text.bk_opaque.then_some(self.text.bk_color),
|
||||
h_align,
|
||||
v_align,
|
||||
clip,
|
||||
})));
|
||||
}
|
||||
|
||||
/// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the
|
||||
/// bitmap, as the mapping does.
|
||||
pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) {
|
||||
let [x, y, w, h] = rect;
|
||||
let a = self.map.device((x, y));
|
||||
let b = self.map.device((x + w, y + h));
|
||||
crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1);
|
||||
let dev = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
|
||||
self.push(Raw::Bitmap { rect: dev, img });
|
||||
}
|
||||
}
|
||||
|
||||
/// An 8×8 hatch tile (HS_HORIZONTAL, HS_VERTICAL, HS_FDIAGONAL, HS_BDIAGONAL, HS_CROSS,
|
||||
/// HS_DIAGCROSS): one-pixel lines in `fg` over `bg`, or over nothing when the background is
|
||||
/// transparent. None for an unknown style.
|
||||
pub(crate) fn hatch_tile(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>) -> Option<Vec<u8>> {
|
||||
if style > 5 {
|
||||
return None;
|
||||
}
|
||||
let mut px = Vec::with_capacity(8 * 8 * 4);
|
||||
for y in 0..8u32 {
|
||||
for x in 0..8u32 {
|
||||
let (h, v, f, b) = (y == 7, x == 7, x == y, x + y == 7);
|
||||
let on = match style {
|
||||
0 => h,
|
||||
1 => v,
|
||||
2 => f,
|
||||
3 => b,
|
||||
4 => h || v,
|
||||
_ => f || b,
|
||||
};
|
||||
px.extend(if on { fg } else { bg.unwrap_or([0, 0, 0, 0]) });
|
||||
}
|
||||
}
|
||||
Some(px)
|
||||
}
|
||||
|
||||
/// Cubic segments from groups of three points (control, control, end). A trailing partial group is
|
||||
/// dropped.
|
||||
fn cubics(pts: &[P]) -> Vec<Op> {
|
||||
let mut ops = Vec::with_capacity(pts.len() / 3);
|
||||
let mut it = pts.iter().copied();
|
||||
while let (Some(a), Some(b), Some(e)) = (it.next(), it.next(), it.next()) {
|
||||
ops.push(Op::C(a, b, e));
|
||||
}
|
||||
ops
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS
|
||||
//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded.
|
||||
|
||||
use crate::{MAX_PIXELS, bytes::Bytes};
|
||||
|
||||
const BI_RGB: u32 = 0;
|
||||
const BI_BITFIELDS: u32 = 3;
|
||||
/// Most palette entries a DIB can carry (the colour table never exceeds 2^8).
|
||||
const MAX_PALETTE: usize = 256;
|
||||
const ROP_SRCAND: u32 = 0x0088_00C6;
|
||||
const ROP_SRCPAINT: u32 = 0x00EE_0086;
|
||||
const ROP_SRCINVERT: u32 = 0x0066_0046;
|
||||
|
||||
/// A decoded picture. `bottom_up` records the DIB's native row order, which source rectangles use.
|
||||
pub(crate) struct Rgba {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub bottom_up: bool,
|
||||
pub px: Vec<u8>,
|
||||
}
|
||||
|
||||
/// How a decoded bitmap combines with the destination.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum Blend {
|
||||
/// A raster operation (BITBLT, STRETCHDIB and friends).
|
||||
Rop(u32),
|
||||
/// EMR_ALPHABLEND: a constant alpha (0..=255), times the per-pixel alpha when `per_pixel` is set
|
||||
/// (AC_SRC_ALPHA: the source colours are premultiplied by it).
|
||||
Alpha { constant: u8, per_pixel: bool },
|
||||
}
|
||||
|
||||
impl Blend {
|
||||
/// Whether the decoder must keep the 32-bit alpha byte of each pixel.
|
||||
pub fn per_pixel(self) -> bool {
|
||||
matches!(self, Blend::Alpha { per_pixel: true, .. })
|
||||
}
|
||||
}
|
||||
|
||||
struct Header {
|
||||
w: usize,
|
||||
h: usize,
|
||||
top_down: bool,
|
||||
bpp: usize,
|
||||
masks: [u32; 3],
|
||||
pal_off: usize,
|
||||
pal_n: usize,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
fn read(info: Bytes) -> Option<Header> {
|
||||
let size = info.u32(0)? as usize;
|
||||
let w = info.i32(4)?;
|
||||
let h = info.i32(8)?;
|
||||
let bpp = info.u16(14)? as usize;
|
||||
let comp = info.u32(16)?;
|
||||
let clr_used = info.u32(32)? as usize;
|
||||
if size < 40 || w <= 0 || h == 0 || !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) || !matches!(comp, BI_RGB | BI_BITFIELDS) {
|
||||
return None;
|
||||
}
|
||||
let masks = if comp == BI_BITFIELDS {
|
||||
[info.u32(40)?, info.u32(44)?, info.u32(48)?]
|
||||
} else if bpp == 16 {
|
||||
[0x7C00, 0x03E0, 0x001F]
|
||||
} else {
|
||||
[0xFF_0000, 0xFF00, 0xFF]
|
||||
};
|
||||
let masks_len = if comp == BI_BITFIELDS && size == 40 { 12 } else { 0 };
|
||||
let pal_off = size.checked_add(masks_len)?;
|
||||
// Indexed formats take 2^bpp entries when clrUsed is 0. Deeper formats carry only the
|
||||
// entries clrUsed names (WMF DIBs can have them), still capped.
|
||||
let pal_n = match (bpp <= 8, clr_used) {
|
||||
(true, 0) => 1usize << bpp,
|
||||
(true, n) => n.min(1usize << bpp),
|
||||
(false, n) => n.min(MAX_PALETTE),
|
||||
};
|
||||
Some(Header { w: w as usize, h: h.unsigned_abs() as usize, top_down: h < 0, bpp, masks, pal_off, pal_n })
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte length of the info part (header, masks, palette) of a DIB, so the pixel bits follow it.
|
||||
pub(crate) fn info_len(dib: Bytes) -> Option<usize> {
|
||||
let h = Header::read(dib)?;
|
||||
h.pal_off.checked_add(h.pal_n.checked_mul(4)?)
|
||||
}
|
||||
|
||||
/// Decodes a DIB given as its info part (header plus masks and palette) and its pixel bits. The
|
||||
/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels
|
||||
/// are opaque.
|
||||
pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option<Rgba> {
|
||||
let hd = Header::read(info)?;
|
||||
let pixels = hd.w.checked_mul(hd.h)?;
|
||||
if pixels == 0 || pixels > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4;
|
||||
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
|
||||
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
|
||||
let mut px = vec![0u8; pixels * 4];
|
||||
for y in 0..hd.h {
|
||||
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
|
||||
let row = src_row * stride;
|
||||
for x in 0..hd.w {
|
||||
let p = pixel(data, row, x, &hd, pal, keep_alpha)?;
|
||||
let o = (y * hd.w + x) * 4;
|
||||
px.get_mut(o..o + 4)?.copy_from_slice(&p);
|
||||
}
|
||||
}
|
||||
Some(Rgba { w: hd.w, h: hd.h, bottom_up: !hd.top_down, px })
|
||||
}
|
||||
|
||||
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
|
||||
let [m_r, m_g, m_b] = hd.masks;
|
||||
let [r, g, b] = match hd.bpp {
|
||||
1 => {
|
||||
let b = *data.get(row + x / 8)?;
|
||||
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
|
||||
}
|
||||
4 => {
|
||||
let b = *data.get(row + x / 2)?;
|
||||
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
|
||||
}
|
||||
8 => palette(pal, usize::from(*data.get(row + x)?)),
|
||||
24 => {
|
||||
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
|
||||
[r, g, b]
|
||||
}
|
||||
16 => {
|
||||
let o = row + x * 2;
|
||||
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
|
||||
[field(v, m_r), field(v, m_g), field(v, m_b)]
|
||||
}
|
||||
_ => {
|
||||
let o = row + x * 4;
|
||||
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
|
||||
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
|
||||
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
|
||||
}
|
||||
};
|
||||
Some([r, g, b, 255])
|
||||
}
|
||||
|
||||
/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image.
|
||||
fn palette(pal: &[u8], i: usize) -> [u8; 3] {
|
||||
pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]])
|
||||
}
|
||||
|
||||
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
|
||||
fn field(v: u32, mask: u32) -> u8 {
|
||||
if mask == 0 {
|
||||
return 0;
|
||||
}
|
||||
let shift = mask.trailing_zeros();
|
||||
let max = u64::from(mask >> shift);
|
||||
let val = u64::from((v & mask) >> shift);
|
||||
((val * 255 + max / 2) / max) as u8
|
||||
}
|
||||
|
||||
/// Clips a source rectangle `src` = `[x, y, w, h]` to the bitmap and returns the cropped pixels (top-down)
|
||||
/// with the part of the destination `dst` that they fill. `src` is in DIB pixel coordinates (`y` counted
|
||||
/// from the DIB's origin, so bottom-up images count from the bottom); a non-positive source size means the
|
||||
/// whole bitmap. Clipping shrinks the destination by the same ratio and shifts it by the clipped-off part,
|
||||
/// so the rest of the image keeps its place. A negative destination extent is kept (the caller mirrors).
|
||||
pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
|
||||
let (iw, ih) = (img.w as i64, img.h as i64);
|
||||
let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] };
|
||||
// The request's top row in top-down order.
|
||||
let top = if img.bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy };
|
||||
let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw));
|
||||
let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih));
|
||||
if x1 <= x0 || r1 <= r0 {
|
||||
return None;
|
||||
}
|
||||
let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64);
|
||||
let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky];
|
||||
let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize);
|
||||
let (ow, oh) = (x1 - x0, r1 - r0);
|
||||
let mut px = Vec::with_capacity(ow * oh * 4);
|
||||
for row in r0..r1 {
|
||||
let s = (row * img.w + x0) * 4;
|
||||
px.extend_from_slice(img.px.get(s..s + ow * 4)?);
|
||||
}
|
||||
Some((Rgba { w: ow, h: oh, bottom_up: false, px }, out))
|
||||
}
|
||||
|
||||
/// Applies a blend to decoded pixels before they are drawn. The picture has no destination pixels, so
|
||||
/// raster operations are drawn as they look over white paper: SRCAND (destination AND source) leaves
|
||||
/// white source pixels out and draws the rest; SRCPAINT and SRCINVERT leave black ones out. Other
|
||||
/// operations draw the source as copied. Constant alpha scales every pixel's alpha, after a
|
||||
/// per-pixel (premultiplied) source is made straight.
|
||||
pub(crate) fn prepare(img: &mut Rgba, blend: Blend) {
|
||||
match blend {
|
||||
Blend::Rop(ROP_SRCAND) => clear_where(img, [255, 255, 255]),
|
||||
Blend::Rop(ROP_SRCPAINT | ROP_SRCINVERT) => clear_where(img, [0, 0, 0]),
|
||||
Blend::Rop(_) => {}
|
||||
Blend::Alpha { constant, per_pixel } => {
|
||||
for p in img.px.as_chunks_mut::<4>().0 {
|
||||
if per_pixel {
|
||||
unpremultiply(p);
|
||||
}
|
||||
p[3] = (u16::from(p[3]) * u16::from(constant) / 255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the pixels of colour `rgb` transparent: where a raster operation keeps the destination.
|
||||
fn clear_where(img: &mut Rgba, rgb: [u8; 3]) {
|
||||
for p in img.px.as_chunks_mut::<4>().0 {
|
||||
if p[..3] == rgb {
|
||||
p[3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AC_SRC_ALPHA sources are premultiplied; the model holds straight RGBA, so divide the colour by the
|
||||
/// alpha (rounded, capped at 255). A transparent pixel keeps its colour bytes.
|
||||
fn unpremultiply(p: &mut [u8; 4]) {
|
||||
let a = u32::from(p[3]);
|
||||
if a == 0 || a == 255 {
|
||||
return;
|
||||
}
|
||||
for c in &mut p[..3] {
|
||||
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors a decoded image left-right and/or top-bottom. Rows come from the buffer as chunks, so a
|
||||
/// short buffer flips only the rows it has.
|
||||
pub(crate) fn flip(img: &mut Rgba, horizontal: bool, vertical: bool) {
|
||||
let (w, h) = (img.w, img.h);
|
||||
if w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let row = w * 4;
|
||||
if horizontal {
|
||||
for line in img.px.chunks_exact_mut(row) {
|
||||
for x in 0..w / 2 {
|
||||
for c in 0..4 {
|
||||
line.swap(x * 4 + c, (w - 1 - x) * 4 + c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if vertical {
|
||||
// Rows the buffer actually holds: a short buffer flips only those.
|
||||
let n = h.min(img.px.len() / row);
|
||||
for y in 0..n / 2 {
|
||||
swap_rows(&mut img.px, y * row, (n - 1 - y) * row, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swaps the `len` bytes at `a` with those at `b` (`a < b`); does nothing when either is out of range.
|
||||
fn swap_rows(px: &mut [u8], a: usize, b: usize, len: usize) {
|
||||
let Some((head, tail)) = px.split_at_mut_checked(b) else { return };
|
||||
if let (Some(top), Some(bottom)) = (head.get_mut(a..a.saturating_add(len)), tail.get_mut(..len)) {
|
||||
top.swap_with_slice(bottom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a
|
||||
//! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::bytes::Bytes;
|
||||
use crate::canvas::{BS_HATCHED, BS_NULL, Brush, Canvas, LogFont, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
|
||||
use crate::dib::{self, Blend};
|
||||
use crate::geom::{self, ArcKind, P};
|
||||
use crate::text::{ETO_GLYPH_INDEX, ETO_PDY, Font, MAX_CHARS, decode_bytes, decode_utf16};
|
||||
use crate::{Error, Frame, MAX_PATH_OPS};
|
||||
|
||||
/// ExtTextOut options: no rectangle in the record (EMR_SMALLTEXTOUT, EmrText), and 8-bit
|
||||
/// characters (EMR_SMALLTEXTOUT).
|
||||
const ETO_NO_RECT: u32 = 0x0100;
|
||||
const ETO_SMALL_CHARS: u32 = 0x0200;
|
||||
|
||||
const EMF_SIGNATURE: u32 = 0x464D_4520;
|
||||
/// Hundredths of a millimetre to points.
|
||||
const HMM_TO_PT: f64 = 72.0 / 2540.0;
|
||||
/// SRCCOPY, the plain copy of a bitmap.
|
||||
const SRCCOPY: u32 = 0x00CC_0020;
|
||||
/// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide.
|
||||
const PS_TYPE_MASK: u32 = 0x000F_0000;
|
||||
const PS_COSMETIC: u32 = 0;
|
||||
|
||||
pub(crate) fn is_emf(b: Bytes) -> bool {
|
||||
b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE)
|
||||
}
|
||||
|
||||
/// The picture frame: `rclFrame` (0.01 mm) mapped to device units through `szlDevice` and
|
||||
/// `szlMillimeters`, sized in points. Without a usable frame, the device bounds stand in (1 unit = 1 point).
|
||||
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
|
||||
if !is_emf(input) {
|
||||
return Err(Error::BadHeader);
|
||||
}
|
||||
let hsize = input.u32(4).ok_or(Error::BadHeader)? as usize;
|
||||
let bounds = rect32(input, 8).ok_or(Error::BadHeader)?;
|
||||
let [l, t, r, b] = bounds;
|
||||
let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None }));
|
||||
let mut cv = Canvas::new();
|
||||
if let Some(px_mm) = device_px_per_mm(input) {
|
||||
cv.set_device_mm(px_mm);
|
||||
}
|
||||
let mut parsed = 0usize;
|
||||
let mut pos = hsize.max(88);
|
||||
while !cv.full() {
|
||||
let (Some(typ), Some(size)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u32(o))) else { break };
|
||||
let size = size as usize;
|
||||
if size < 8 || !size.is_multiple_of(4) {
|
||||
break;
|
||||
}
|
||||
let Some(rec) = input.slice(pos, size) else { break };
|
||||
if typ == 14 {
|
||||
break;
|
||||
}
|
||||
record(&mut cv, typ, Bytes(rec));
|
||||
parsed += 1;
|
||||
let Some(next) = pos.checked_add(size) else { break };
|
||||
pos = next;
|
||||
}
|
||||
if parsed == 0 {
|
||||
return Err(Error::NoRecords);
|
||||
}
|
||||
Ok((cv, frame))
|
||||
}
|
||||
|
||||
/// Device pixels per millimetre on each axis, from `szlDevice` (72) and `szlMillimeters` (80).
|
||||
fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> {
|
||||
let (dw, dh) = (input.i32(72)?, input.i32(76)?);
|
||||
let (mw, mh) = (input.i32(80)?, input.i32(84)?);
|
||||
(dw > 0 && dh > 0 && mw > 0 && mh > 0).then(|| (f64::from(dw) / f64::from(mw), f64::from(dh) / f64::from(mh)))
|
||||
}
|
||||
|
||||
/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header.
|
||||
fn frame_from_header(input: Bytes) -> Option<Frame> {
|
||||
let [fl, ft, fr, fb] = rect32(input, 24)?;
|
||||
if fr <= fl || fb <= ft {
|
||||
return None;
|
||||
}
|
||||
// Device units per hundredth of a millimetre.
|
||||
let (px_mm_x, px_mm_y) = device_px_per_mm(input)?;
|
||||
let (sx, sy) = (px_mm_x / 100.0, px_mm_y / 100.0);
|
||||
let rect = [fl * sx, ft * sy, (fr - fl) * sx, (fb - ft) * sy];
|
||||
Some(Frame { rect, size: Some(((fr - fl) * HMM_TO_PT, (fb - ft) * HMM_TO_PT)) })
|
||||
}
|
||||
|
||||
fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
|
||||
if cv.full() {
|
||||
return;
|
||||
}
|
||||
let _ = match typ {
|
||||
9 => xy32(r, 8).map(|p| cv.set_window_ext(p)),
|
||||
10 => xy32(r, 8).map(|p| cv.set_window_org(p)),
|
||||
11 => xy32(r, 8).map(|p| cv.set_viewport_ext(p)),
|
||||
12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)),
|
||||
17 => r.u32(8).map(|m| cv.set_map_mode(m)),
|
||||
19 => r.u32(8).map(|m| cv.set_polyfill(m)),
|
||||
20 => r.u32(8).map(|m| cv.set_rop2(m)),
|
||||
33 => {
|
||||
cv.save_dc();
|
||||
Some(())
|
||||
}
|
||||
34 => r.i32(8).map(|n| cv.restore_dc(n)),
|
||||
35 => xform(r, 8).map(|x| cv.modify_world(x, 4)),
|
||||
36 => xform(r, 8).zip(r.u32(32)).map(|(x, m)| cv.modify_world(x, m)),
|
||||
37 => r.u32(8).map(|i| cv.select(i)),
|
||||
38 => create_pen(cv, r),
|
||||
95 => create_ext_pen(cv, r),
|
||||
39 => create_brush(cv, r),
|
||||
93 | 94 => create_dib_brush(cv, r, typ == 93),
|
||||
82 => create_font(cv, r),
|
||||
18 => r.u32(8).map(|m| cv.set_bk_mode(m)),
|
||||
22 => r.u32(8).map(|a| cv.set_text_align(a)),
|
||||
24 => r.u32(8).map(|c| cv.set_text_color(c)),
|
||||
25 => r.u32(8).map(|c| cv.set_bk_color(c)),
|
||||
83 | 84 => ext_text_out(cv, r, typ == 84),
|
||||
108 => small_text_out(cv, r),
|
||||
40 => r.u32(8).map(|i| cv.delete_object(i)),
|
||||
27 => xy32(r, 8).map(|p| cv.move_to(p)),
|
||||
54 => xy32(r, 8).map(|p| cv.line_to(p)),
|
||||
42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
|
||||
43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
|
||||
44 => {
|
||||
let (cw, ch) = (r.i32(24), r.i32(28));
|
||||
let (Some(cw), Some(ch)) = (cw, ch) else { return };
|
||||
rect32(r, 8).map(|[l, t, rr, b]| {
|
||||
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true);
|
||||
})
|
||||
}
|
||||
45..=47 => arc32(r).map(|(bounds, s, e)| {
|
||||
let kind = match typ {
|
||||
45 => ArcKind::Arc,
|
||||
46 => ArcKind::Chord,
|
||||
_ => ArcKind::Pie,
|
||||
};
|
||||
let [l, t, rr, b] = bounds;
|
||||
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
|
||||
}),
|
||||
4 | 87 => counted(r, typ == 4).map(|pts| cv.poly(&pts, false, false, true)),
|
||||
3 | 86 => counted(r, typ == 3).map(|pts| cv.poly(&pts, true, true, true)),
|
||||
6 | 89 => counted(r, typ == 6).map(|pts| cv.line_chain(&pts, false, true)),
|
||||
// POLYBEZIER starts its own figure at the first point and leaves the current point alone;
|
||||
// POLYBEZIERTO continues from the current point.
|
||||
2 | 85 => counted(r, typ == 2).and_then(|pts| {
|
||||
let from = *pts.first()?;
|
||||
cv.bezier(from, pts.get(1..)?);
|
||||
Some(())
|
||||
}),
|
||||
5 | 88 => counted(r, typ == 5).map(|pts| cv.bezier_to(&pts)),
|
||||
7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, false), false, true)),
|
||||
8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, true), true, true)),
|
||||
59 => {
|
||||
cv.begin_path();
|
||||
Some(())
|
||||
}
|
||||
60 => {
|
||||
cv.end_path();
|
||||
Some(())
|
||||
}
|
||||
61 => {
|
||||
cv.close_figure();
|
||||
Some(())
|
||||
}
|
||||
62 => {
|
||||
cv.paint_path(true, false);
|
||||
Some(())
|
||||
}
|
||||
63 => {
|
||||
cv.paint_path(true, true);
|
||||
Some(())
|
||||
}
|
||||
64 => {
|
||||
cv.paint_path(false, true);
|
||||
Some(())
|
||||
}
|
||||
76 | 77 => bitblt(cv, r, typ == 77),
|
||||
80 => set_dibits(cv, r),
|
||||
81 => stretch_dibits(cv, r),
|
||||
114 => alphablend(cv, r),
|
||||
_ => Some(()),
|
||||
};
|
||||
}
|
||||
|
||||
/// Two little-endian i32 values at `off`, as a point.
|
||||
fn xy32(r: Bytes, off: usize) -> Option<P> {
|
||||
Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?)))
|
||||
}
|
||||
|
||||
/// RECTL (left, top, right, bottom) as i32 values widened to f64.
|
||||
fn rect32(r: Bytes, off: usize) -> Option<[f64; 4]> {
|
||||
Some([f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?), f64::from(r.i32(off + 8)?), f64::from(r.i32(off + 12)?)])
|
||||
}
|
||||
|
||||
/// An i32 field as f64; 0 past the end.
|
||||
fn i32s(r: Bytes, off: usize) -> f64 {
|
||||
r.i32(off).map_or(0.0, f64::from)
|
||||
}
|
||||
|
||||
/// XFORM: six f32 values in M11, M12, M21, M22, Dx, Dy order.
|
||||
fn xform(r: Bytes, off: usize) -> Option<Xform> {
|
||||
let mut x = [0.0f64; 6];
|
||||
for (i, v) in x.iter_mut().enumerate() {
|
||||
*v = f64::from(r.f32(off + i * 4)?);
|
||||
}
|
||||
Some(x)
|
||||
}
|
||||
|
||||
/// ARC, CHORD, PIE: bounds at 8, start point at 24, end point at 32.
|
||||
fn arc32(r: Bytes) -> Option<([f64; 4], P, P)> {
|
||||
Some((rect32(r, 8)?, xy32(r, 24)?, xy32(r, 32)?))
|
||||
}
|
||||
|
||||
/// A point count from a record, rejected when it exceeds the path budget (before any allocation).
|
||||
fn budget(n: u32) -> Option<usize> {
|
||||
let n = n as usize;
|
||||
(n <= MAX_PATH_OPS).then_some(n)
|
||||
}
|
||||
|
||||
/// Point count at 24, then the points (32-bit or 16-bit) at 28.
|
||||
fn counted(r: Bytes, wide: bool) -> Option<Vec<P>> {
|
||||
let n = budget(r.u32(24)?)?;
|
||||
r.tail(28)?.points(0, n, wide)
|
||||
}
|
||||
|
||||
/// Subpath count at 24, total point count at 28, per-subpath counts at 32, then the points.
|
||||
fn poly_groups(r: Bytes, wide: bool) -> Option<(Vec<usize>, Vec<P>)> {
|
||||
let n = budget(r.u32(24)?)?;
|
||||
// The record must hold the n counts before anything is allocated for them.
|
||||
let raw = r.slice(32, n.checked_mul(4)?)?;
|
||||
let counts: Vec<usize> = raw.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as usize).collect();
|
||||
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
|
||||
let total = budget(u32::try_from(total).ok()?)?;
|
||||
let pts = r.tail(n.checked_mul(4)?.checked_add(32)?)?.points(0, total, wide)?;
|
||||
Some((counts, pts))
|
||||
}
|
||||
|
||||
/// EMR_CREATEPEN: handle, LogPen (style, width x/y, colour).
|
||||
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let style = r.u32(12)?;
|
||||
let width = r.i32(16)?;
|
||||
let color = r.u32(24)?;
|
||||
cv.add_object(Some(slot), Obj::Pen(Pen { style, width: f64::from(width), color: colorref(color) }));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_EXTCREATEPEN: handle, bitmap offsets (unused), LogPenEx (style, width, brush style, colour).
|
||||
/// A cosmetic pen's width is in device pixels (always 1), so it is a hairline; a BS_NULL brush draws
|
||||
/// no line at all.
|
||||
fn create_ext_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let (style, width, brush, color) = (r.u32(28)?, r.u32(32)?, r.u32(36)?, r.u32(40)?);
|
||||
let width = if style & PS_TYPE_MASK == PS_COSMETIC { 0.0 } else { f64::from(width) };
|
||||
let style = if brush == BS_NULL { PS_NULL } else { style & PS_STYLE_MASK };
|
||||
cv.add_object(Some(slot), Obj::Pen(Pen { style, width, color: colorref(color) }));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_CREATEBRUSHINDIRECT: handle, LogBrush32 (style, colour, hatch).
|
||||
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let style = r.u32(12)?;
|
||||
let color = r.u32(16)?;
|
||||
let mut b = Brush::new(style, colorref(color));
|
||||
if style == BS_HATCHED {
|
||||
b.hatch = r.u32(20).unwrap_or(0);
|
||||
}
|
||||
cv.add_object(Some(slot), Obj::Brush(b));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_CREATEDIBPATTERNBRUSHPT (94) and EMR_CREATEMONOBRUSH (93): handle, colour usage, then the
|
||||
/// bitmap info and bits as offset/size pairs. A mono brush (or one whose palette holds indices,
|
||||
/// DIB_PAL_COLORS) takes the text and background colours. An undecodable bitmap leaves the slot
|
||||
/// empty.
|
||||
fn create_dib_brush(cv: &mut Canvas, r: Bytes, mono: bool) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let usage = r.u32(12)?;
|
||||
let (bo, bl) = (r.u32(16)? as usize, r.u32(20)? as usize);
|
||||
let (po, pl) = (r.u32(24)? as usize, r.u32(28)? as usize);
|
||||
let img = dib::decode(Bytes(r.slice(bo, bl)?), Bytes(r.slice(po, pl)?), false)?;
|
||||
let b = Brush::pattern(img, mono || usage == 1)?;
|
||||
cv.add_object(Some(slot), Obj::Brush(b));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_EXTCREATEFONTINDIRECTW: handle, then a LogFont (MS-EMF 2.2.13): height, width, escapement,
|
||||
/// orientation, weight (i32 each), italic, underline, strike-out, charset and four more bytes, then
|
||||
/// the face name in 32 UTF-16 units (NUL-terminated).
|
||||
fn create_font(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let slot = r.u32(8)? as usize;
|
||||
let height = r.i32(12)?;
|
||||
let width = r.i32(16).unwrap_or(0);
|
||||
let escapement = r.i32(20).unwrap_or(0);
|
||||
let weight = r.i32(28).unwrap_or(0);
|
||||
let byte = |o: usize| r.0.get(o).copied().unwrap_or(0);
|
||||
let units: Vec<u16> = (0..32).map_while(|i| r.u16(40 + 2 * i)).take_while(|u| *u != 0).collect();
|
||||
let (family, _) = decode_utf16(&units, &[]);
|
||||
let font = Font {
|
||||
family: family.trim().to_string(),
|
||||
height: height as f32,
|
||||
width: width as f32,
|
||||
weight: weight.clamp(0, 1000) as u16,
|
||||
italic: byte(32) != 0,
|
||||
underline: byte(33) != 0,
|
||||
strike: byte(34) != 0,
|
||||
charset: byte(35),
|
||||
};
|
||||
cv.add_object(Some(slot), Obj::Font(Arc::new(LogFont { font, escapement: f64::from(escapement) })));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The dx array at `off` for `n` characters: i32 advances, or (dx, dy) pairs under ETO_PDY (only
|
||||
/// dx is used). Empty when absent or short.
|
||||
fn dx_array(r: Bytes, off: usize, n: usize, opts: u32) -> Vec<f32> {
|
||||
let step = if opts & ETO_PDY != 0 { 8 } else { 4 };
|
||||
if off == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let dx: Vec<f32> = (0..n).map_while(|i| r.i32(off.checked_add(i.checked_mul(step)?)?)).map(|v| v as f32).collect();
|
||||
if dx.len() == n { dx } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// EMR_EXTTEXTOUTA (83) and EMR_EXTTEXTOUTW (84): bounds, graphics mode and scales, then an EmrText
|
||||
/// at 36: reference point, character count, string offset, options, the rectangle (absent under
|
||||
/// ETO_NO_RECT) and the dx offset. Offsets count from the record start. Glyph-index strings name
|
||||
/// glyphs of a font the reader cannot match, so they are left out.
|
||||
fn ext_text_out(cv: &mut Canvas, r: Bytes, wide: bool) -> Option<()> {
|
||||
let at = xy32(r, 36)?;
|
||||
let n = (r.u32(44)? as usize).min(MAX_CHARS);
|
||||
let off = r.u32(48)? as usize;
|
||||
let opts = r.u32(52)?;
|
||||
let (rect, dx_off) = if opts & ETO_NO_RECT != 0 { (None, r.u32(56)?) } else { (rect32(r, 56), r.u32(72)?) };
|
||||
if opts & ETO_GLYPH_INDEX != 0 {
|
||||
return None;
|
||||
}
|
||||
let dx = dx_array(r, dx_off as usize, n, opts);
|
||||
let (text, dx) = if wide {
|
||||
let raw = r.slice(off, n.checked_mul(2)?)?;
|
||||
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
|
||||
decode_utf16(&units, &dx)
|
||||
} else {
|
||||
decode_bytes(r.slice(off, n)?, cv.font_charset(), &dx)
|
||||
};
|
||||
cv.text(at, text, dx, opts, rect);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// EMR_SMALLTEXTOUT (MS-EMF 2.3.5.37): x, y, character count, options, graphics mode, scales, the
|
||||
/// bounds (absent under ETO_NO_RECT), then the characters: 8-bit under ETO_SMALL_CHARS, else UTF-16.
|
||||
fn small_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let at = xy32(r, 8)?;
|
||||
let n = (r.u32(16)? as usize).min(MAX_CHARS);
|
||||
let opts = r.u32(20)?;
|
||||
if opts & ETO_GLYPH_INDEX != 0 {
|
||||
return None;
|
||||
}
|
||||
let (rect, off) = if opts & ETO_NO_RECT != 0 { (None, 36) } else { (rect32(r, 36), 52) };
|
||||
let (text, _) = if opts & ETO_SMALL_CHARS != 0 {
|
||||
decode_bytes(r.slice(off, n)?, cv.font_charset(), &[])
|
||||
} else {
|
||||
let raw = r.slice(off, n.checked_mul(2)?)?;
|
||||
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
|
||||
decode_utf16(&units, &[])
|
||||
};
|
||||
cv.text(at, text, Vec::new(), opts, rect);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at
|
||||
/// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead.
|
||||
fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> {
|
||||
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
|
||||
let rop = r.u32(40)?;
|
||||
if Canvas::sourceless(rop) {
|
||||
cv.fill_rop(dst, rop);
|
||||
return Some(());
|
||||
}
|
||||
let (sw, sh) = if stretch { (i32s(r, 100), i32s(r, 104)) } else { (dst[2], dst[3]) };
|
||||
let src = [i32s(r, 44), i32s(r, 48), sw, sh];
|
||||
blit(cv, r, dst, src, (84, 92), Blend::Rop(rop))
|
||||
}
|
||||
|
||||
/// ALPHABLEND: laid out like STRETCHBLT, with a BLENDFUNCTION at 40. Constant alpha at 42 applies to
|
||||
/// every pixel; AlphaFormat at 43 with AC_SRC_ALPHA (1) also uses the per-pixel alpha.
|
||||
fn alphablend(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
|
||||
let constant = *r.0.get(42)?;
|
||||
let per_pixel = (*r.0.get(43)? & 0x01) != 0;
|
||||
let src = [i32s(r, 44), i32s(r, 48), i32s(r, 100), i32s(r, 104)];
|
||||
blit(cv, r, dst, src, (84, 92), Blend::Alpha { constant, per_pixel })
|
||||
}
|
||||
|
||||
/// STRETCHDIBITS: dest x/y at 24, src x/y/w/h at 32..48, bmi offset/size at 48, bits at 56, rop at 68,
|
||||
/// dest w/h at 72.
|
||||
fn stretch_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 72), i32s(r, 76)];
|
||||
let src = [i32s(r, 32), i32s(r, 36), i32s(r, 40), i32s(r, 44)];
|
||||
let rop = r.u32(68)?;
|
||||
blit(cv, r, dst, src, (48, 56), Blend::Rop(rop))
|
||||
}
|
||||
|
||||
/// SETDIBITSTODEVICE (MS-EMF 2.3.1.5): dest x/y at 24, src x/y at 32, size (cxSrc, cySrc) at 40, bmi
|
||||
/// at 48, bits at 56, start scan at 68. The image keeps its own size; the bits begin at scan line
|
||||
/// `iStartScan`, so the source y counts from there.
|
||||
fn set_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let (cx, cy) = (i32s(r, 40), i32s(r, 44));
|
||||
if cx <= 0.0 || cy <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let dst = [i32s(r, 24), i32s(r, 28), cx, cy];
|
||||
let src = [i32s(r, 32), i32s(r, 36) - f64::from(r.u32(68)?), cx, cy];
|
||||
blit(cv, r, dst, src, (48, 56), Blend::Rop(SRCCOPY))
|
||||
}
|
||||
|
||||
/// Decodes the bitmap at `at` = (offset of the bmi offset/length pair, offset of the bits pair), both
|
||||
/// inside the record, and stretches the source rectangle `src` into `dst`. Both are `[x, y, w, h]`; a
|
||||
/// non-positive source size means the whole bitmap. `blend` decides how it combines.
|
||||
fn blit(cv: &mut Canvas, r: Bytes, dst: [f64; 4], src: [f64; 4], at: (usize, usize), blend: Blend) -> Option<()> {
|
||||
let (bo, bl) = (r.u32(at.0)? as usize, r.u32(at.0 + 4)? as usize);
|
||||
let (po, pl) = (r.u32(at.1)? as usize, r.u32(at.1 + 4)? as usize);
|
||||
if pl == 0 {
|
||||
return None;
|
||||
}
|
||||
let info = Bytes(r.slice(bo, bl)?);
|
||||
let pixels = Bytes(r.slice(po, pl)?);
|
||||
let mut img = dib::decode(info, pixels, blend.per_pixel())?;
|
||||
dib::prepare(&mut img, blend);
|
||||
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
|
||||
cv.bitmap(dst, img);
|
||||
Some(())
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Path operations in `f64` and the shape builders that turn WMF/EMF rectangles, ellipses and arcs
|
||||
//! into cubic Béziers (the classic 4/3·tan(Δ/4) approximation, at most 90° per piece).
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
pub(crate) type P = (f64, f64);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Op {
|
||||
M(P),
|
||||
L(P),
|
||||
C(P, P, P),
|
||||
Z,
|
||||
}
|
||||
|
||||
pub(crate) fn rect(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
|
||||
vec![Op::M((l, t)), Op::L((r, t)), Op::L((r, b)), Op::L((l, b)), Op::Z]
|
||||
}
|
||||
|
||||
/// Corner radii are half the EMF/WMF corner ellipse size, clamped so the corners never overlap.
|
||||
pub(crate) fn round_rect(l: f64, t: f64, r: f64, b: f64, ew: f64, eh: f64) -> Vec<Op> {
|
||||
// Reversed corners (right < left, bottom < top) describe the same rectangle.
|
||||
let (l, r) = (l.min(r), l.max(r));
|
||||
let (t, b) = (t.min(b), t.max(b));
|
||||
let rx = (ew / 2.0).abs().min((r - l) / 2.0);
|
||||
let ry = (eh / 2.0).abs().min((b - t) / 2.0);
|
||||
let mut ops = vec![Op::M((l + rx, t)), Op::L((r - rx, t))];
|
||||
ops.extend(arc(r - rx, t + ry, rx, ry, -FRAC_PI_2, FRAC_PI_2));
|
||||
ops.push(Op::L((r, b - ry)));
|
||||
ops.extend(arc(r - rx, b - ry, rx, ry, 0.0, FRAC_PI_2));
|
||||
ops.push(Op::L((l + rx, b)));
|
||||
ops.extend(arc(l + rx, b - ry, rx, ry, FRAC_PI_2, FRAC_PI_2));
|
||||
ops.push(Op::L((l, t + ry)));
|
||||
ops.extend(arc(l + rx, t + ry, rx, ry, PI, FRAC_PI_2));
|
||||
ops.push(Op::Z);
|
||||
ops
|
||||
}
|
||||
|
||||
pub(crate) fn ellipse(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
|
||||
let (cx, cy, rx, ry) = center(l, t, r, b);
|
||||
let mut ops = vec![Op::M((cx + rx, cy))];
|
||||
ops.extend(arc(cx, cy, rx, ry, 0.0, TAU));
|
||||
ops.push(Op::Z);
|
||||
ops
|
||||
}
|
||||
|
||||
/// Which closed or open shape an ARC/CHORD/PIE record draws.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ArcKind {
|
||||
Arc,
|
||||
Chord,
|
||||
Pie,
|
||||
}
|
||||
|
||||
/// ARC, CHORD and PIE: the arc runs counter-clockwise on screen from the start point to the end point.
|
||||
pub(crate) fn arc_shape(l: f64, t: f64, r: f64, b: f64, s: P, e: P, kind: ArcKind) -> Vec<Op> {
|
||||
let (cx, cy, rx, ry) = center(l, t, r, b);
|
||||
let ang = |p: P| ((p.1 - cy) / ry.max(f64::MIN_POSITIVE)).atan2((p.0 - cx) / rx.max(f64::MIN_POSITIVE));
|
||||
let (a0, a1) = (ang(s), ang(e));
|
||||
let mut d = (a0 - a1).rem_euclid(TAU);
|
||||
if d < 1e-12 {
|
||||
d = TAU;
|
||||
}
|
||||
let sweep = -d;
|
||||
let start = (cx + rx * a0.cos(), cy + ry * a0.sin());
|
||||
let mut ops = if kind == ArcKind::Pie { vec![Op::M((cx, cy)), Op::L(start)] } else { vec![Op::M(start)] };
|
||||
ops.extend(arc(cx, cy, rx, ry, a0, sweep));
|
||||
match kind {
|
||||
ArcKind::Arc => {}
|
||||
ArcKind::Chord => ops.push(Op::Z),
|
||||
ArcKind::Pie => ops.extend([Op::L((cx, cy)), Op::Z]),
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
fn center(l: f64, t: f64, r: f64, b: f64) -> (f64, f64, f64, f64) {
|
||||
((l + r) / 2.0, (t + b) / 2.0, (r - l).abs() / 2.0, (b - t).abs() / 2.0)
|
||||
}
|
||||
|
||||
/// Cubic pieces along an ellipse from parameter `a0` over signed `sweep` radians. The current point
|
||||
/// must already be at `(cx + rx cos a0, cy + ry sin a0)`.
|
||||
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec<Op> {
|
||||
let n = ((sweep.abs() / FRAC_PI_2).ceil() as usize).clamp(1, 8);
|
||||
let step = sweep / n as f64;
|
||||
let k = 4.0 / 3.0 * (step / 4.0).tan();
|
||||
let pt = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin());
|
||||
let mut ops = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let t0 = a0 + step * i as f64;
|
||||
let t1 = t0 + step;
|
||||
let (p0, p3) = (pt(t0), pt(t1));
|
||||
let c1 = (p0.0 - k * rx * t0.sin(), p0.1 + k * ry * t0.cos());
|
||||
let c2 = (p3.0 + k * rx * t1.sin(), p3.1 - k * ry * t1.cos());
|
||||
ops.push(Op::C(c1, c2, p3));
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the
|
||||
/// point list are cut short rather than failing the whole record.
|
||||
pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec<Op> {
|
||||
let mut ops = Vec::with_capacity(pts.len() + counts.len());
|
||||
let mut at = 0usize;
|
||||
for &c in counts {
|
||||
let end = at.saturating_add(c).min(pts.len());
|
||||
let Some((first, rest)) = pts.get(at..end).and_then(|s| s.split_first()) else {
|
||||
at = end;
|
||||
continue;
|
||||
};
|
||||
ops.push(Op::M(*first));
|
||||
ops.extend(rest.iter().map(|p| Op::L(*p)));
|
||||
if close {
|
||||
ops.push(Op::Z);
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
ops
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF)
|
||||
//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths, pattern
|
||||
//! fills, RGBA bitmaps and text strings, in a picture frame with y down.
|
||||
//!
|
||||
//! Text stays font-agnostic: a [`Text`] item names the font the file asks for and carries the
|
||||
//! string, its advances and alignment; the renderer resolves the font and draws the glyphs.
|
||||
//!
|
||||
//! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is
|
||||
//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`],
|
||||
//! never a panic.
|
||||
//!
|
||||
//! Records follow MS-WMF and MS-EMF. Records outside the supported set are skipped silently.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bytes;
|
||||
mod canvas;
|
||||
mod dib;
|
||||
mod emf;
|
||||
mod geom;
|
||||
mod place;
|
||||
mod text;
|
||||
mod wmf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use canvas::Raw;
|
||||
use geom::{Op, P};
|
||||
pub use place::{MAX_BITMAP_SIDE, PlacedItem};
|
||||
pub use text::{Font, HAlign, SYMBOL_CHARSET, Text, VAlign};
|
||||
|
||||
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
|
||||
pub(crate) const MAX_ITEMS: usize = 200_000;
|
||||
/// Object table slots (WMF handles, EMF object indices).
|
||||
pub(crate) const MAX_OBJECTS: usize = 4096;
|
||||
/// Largest bitmap in pixels (about 40 megapixels, 160 MB of RGBA).
|
||||
pub(crate) const MAX_PIXELS: usize = 40_000_000;
|
||||
/// DC save-stack depth.
|
||||
pub(crate) const MAX_STACK: usize = 1024;
|
||||
/// Points in one open path bracket.
|
||||
pub(crate) const MAX_PATH_OPS: usize = 2_000_000;
|
||||
/// Bytes of path ops and pixels kept from one file (about 256 MB).
|
||||
pub(crate) const MAX_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
/// A parsed metafile. Items use coordinates in `0..width` and `0..height`, y down.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Picture {
|
||||
/// Size of the drawing in its own units: points for placeable WMF and EMF (from the frame), device
|
||||
/// or logical units otherwise.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Item {
|
||||
/// A filled and/or stroked path, in picture coordinates.
|
||||
Path {
|
||||
segs: Vec<Seg>,
|
||||
fill: Option<[u8; 4]>,
|
||||
/// Colour and width in picture units.
|
||||
stroke: Option<([u8; 4], f32)>,
|
||||
even_odd: bool,
|
||||
},
|
||||
/// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down.
|
||||
Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec<u8> },
|
||||
/// A path filled with a repeating tile (pattern and hatch brushes). Items filled with the same
|
||||
/// brush share its tile.
|
||||
Pattern { segs: Vec<Seg>, even_odd: bool, tile: Arc<Tile> },
|
||||
/// A text string.
|
||||
Text(Box<Text>),
|
||||
}
|
||||
|
||||
/// A brush tile: RGBA8 rows top-down (transparent where the brush leaves the background), each
|
||||
/// pixel `cell` picture units, with a tile's top-left corner at `origin`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Tile {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
pub cell: [f32; 2],
|
||||
pub origin: [f32; 2],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Seg {
|
||||
Move(f32, f32),
|
||||
Line(f32, f32),
|
||||
Cubic(f32, f32, f32, f32, f32, f32),
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("not a WMF or EMF file")]
|
||||
NotMetafile,
|
||||
#[error("metafile header is truncated or invalid")]
|
||||
BadHeader,
|
||||
#[error("metafile has no readable records")]
|
||||
NoRecords,
|
||||
}
|
||||
|
||||
/// The picture frame in device units (`rect` = `[x, y, w, h]`) and, when the file states one, its
|
||||
/// size in picture units.
|
||||
pub(crate) struct Frame {
|
||||
pub rect: [f64; 4],
|
||||
pub size: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Parses a WMF (placeable or plain) or EMF file.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Picture, Error> {
|
||||
let b = Bytes(bytes);
|
||||
let (cv, frame) = if emf::is_emf(b) {
|
||||
emf::parse(b)?
|
||||
} else if wmf::is_wmf(b) {
|
||||
// A WMF converted from an EMF can carry the original in MFCOMMENT escapes; PowerPoint draws
|
||||
// the WMF's own records (its dx rounding shows), so the copy is not used.
|
||||
wmf::parse(b)?
|
||||
} else {
|
||||
return Err(Error::NotMetafile);
|
||||
};
|
||||
Ok(build(cv.items, frame))
|
||||
}
|
||||
|
||||
/// True when the bytes start like a WMF (placeable or plain header) or an EMF.
|
||||
pub fn is_metafile(bytes: &[u8]) -> bool {
|
||||
let b = Bytes(bytes);
|
||||
emf::is_emf(b) || wmf::is_wmf(b)
|
||||
}
|
||||
|
||||
/// Maps device-space output into the picture frame. Without a usable frame the frame is the bounding
|
||||
/// box of the drawing, so the origin is still the top-left of what was drawn.
|
||||
fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
|
||||
let frame = frame.filter(|f| f.rect.iter().all(|v| v.is_finite()) && f.rect[2] > 0.0 && f.rect[3] > 0.0);
|
||||
let (rect, size) = match frame {
|
||||
Some(f) => (f.rect, f.size.unwrap_or((f.rect[2], f.rect[3]))),
|
||||
None => match bbox(&raw) {
|
||||
Some([x0, y0, x1, y1]) => ([x0, y0, x1 - x0, y1 - y0], (x1 - x0, y1 - y0)),
|
||||
None => return Picture { width: 0.0, height: 0.0, items: Vec::new() },
|
||||
},
|
||||
};
|
||||
let sx = if rect[2] > 0.0 { size.0 / rect[2] } else { 1.0 };
|
||||
let sy = if rect[3] > 0.0 { size.1 / rect[3] } else { 1.0 };
|
||||
let tf = move |p: P| ((p.0 - rect[0]) * sx, (p.1 - rect[1]) * sy);
|
||||
let stroke_scale = (sx + sy) / 2.0;
|
||||
// No stroke is wider than the picture's diagonal: anything wider is a mapping artefact.
|
||||
let diag = size.0.hypot(size.1);
|
||||
// Tiles shared by several fills stay shared.
|
||||
let mut tiles: HashMap<*const canvas::RawTile, (Arc<canvas::RawTile>, Arc<Tile>)> = HashMap::new();
|
||||
let items = raw.into_iter().filter_map(|r| match r {
|
||||
Raw::Path { ops, fill, stroke, even_odd } => {
|
||||
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
|
||||
if segs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?)));
|
||||
Some(Item::Path { segs, fill, stroke, even_odd })
|
||||
}
|
||||
Raw::Bitmap { rect: r, img } => {
|
||||
let (x, y) = tf((r[0], r[1]));
|
||||
let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?];
|
||||
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px })
|
||||
}
|
||||
Raw::Pattern { ops, even_odd, tile } => {
|
||||
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
|
||||
if segs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let key = Arc::as_ptr(&tile);
|
||||
let tile = match tiles.get(&key) {
|
||||
Some((_, t)) => t.clone(),
|
||||
None => {
|
||||
let (ox, oy) = tf((0.0, 0.0));
|
||||
let t = Arc::new(Tile {
|
||||
width: u32::try_from(tile.w).ok()?,
|
||||
height: u32::try_from(tile.h).ok()?,
|
||||
rgba: tile.px.clone(),
|
||||
cell: [f(tile.cell * sx)?, f(tile.cell * sy)?],
|
||||
origin: [f(ox)?, f(oy)?],
|
||||
});
|
||||
// The raw tile is kept with its key, so the address is never reused by another.
|
||||
tiles.insert(key, (tile.clone(), t.clone()));
|
||||
t
|
||||
}
|
||||
};
|
||||
Some(Item::Pattern { segs, even_odd, tile })
|
||||
}
|
||||
Raw::Text(mut t) => {
|
||||
// Device to picture: scale by (sx, sy) after moving the frame's corner to the origin.
|
||||
let [a, b, c, d, e, g] = t.transform;
|
||||
let (e, g) = tf((e, g));
|
||||
t.transform = [a * sx, b * sy, c * sx, d * sy, e, g];
|
||||
if !t.transform.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if let Some([x0, y0, x1, y1]) = t.clip {
|
||||
let (p, q) = (tf((f64::from(x0), f64::from(y0))), tf((f64::from(x1), f64::from(y1))));
|
||||
t.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]);
|
||||
}
|
||||
Some(Item::Text(t))
|
||||
}
|
||||
});
|
||||
let items = items.collect();
|
||||
Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items }
|
||||
}
|
||||
|
||||
fn f(v: f64) -> Option<f32> {
|
||||
let v = v as f32;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
fn seg(op: &Op, tf: impl Fn(P) -> P) -> Option<Seg> {
|
||||
let pt = |p: P| {
|
||||
let (x, y) = tf(p);
|
||||
Some((f(x)?, f(y)?))
|
||||
};
|
||||
Some(match *op {
|
||||
Op::M(p) => {
|
||||
let (x, y) = pt(p)?;
|
||||
Seg::Move(x, y)
|
||||
}
|
||||
Op::L(p) => {
|
||||
let (x, y) = pt(p)?;
|
||||
Seg::Line(x, y)
|
||||
}
|
||||
Op::C(a, b, c) => {
|
||||
let (x1, y1) = pt(a)?;
|
||||
let (x2, y2) = pt(b)?;
|
||||
let (x3, y3) = pt(c)?;
|
||||
Seg::Cubic(x1, y1, x2, y2, x3, y3)
|
||||
}
|
||||
Op::Z => Seg::Close,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounding box `[x0, y0, x1, y1]` over the finite points of the output.
|
||||
fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
|
||||
let mut b: Option<[f64; 4]> = None;
|
||||
let mut grow = |p: P| {
|
||||
if p.0.is_finite() && p.1.is_finite() {
|
||||
b = Some(match b {
|
||||
None => [p.0, p.1, p.0, p.1],
|
||||
Some([x0, y0, x1, y1]) => [x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)],
|
||||
});
|
||||
}
|
||||
};
|
||||
for r in raw {
|
||||
match r {
|
||||
Raw::Path { ops, .. } | Raw::Pattern { ops, .. } => {
|
||||
for op in ops {
|
||||
match *op {
|
||||
Op::M(p) | Op::L(p) => grow(p),
|
||||
Op::C(a, c, d) => {
|
||||
grow(a);
|
||||
grow(c);
|
||||
grow(d);
|
||||
}
|
||||
Op::Z => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Raw::Bitmap { rect, .. } => {
|
||||
grow((rect[0], rect[1]));
|
||||
grow((rect[0] + rect[2], rect[1] + rect[3]));
|
||||
}
|
||||
Raw::Text(t) => grow((t.transform[4], t.transform[5])),
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Placing a picture on a target (a page or a device): the fitted transform, paths as kurbo paths in
|
||||
//! target units and bitmaps as target rectangles. The rasteriser and the PDF writer only paint the result.
|
||||
|
||||
use kurbo::{Affine, BezPath, Rect, Shape};
|
||||
|
||||
use crate::{Item, Picture, Seg};
|
||||
|
||||
/// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides.
|
||||
pub const MAX_BITMAP_SIDE: u32 = 30_000;
|
||||
/// Placed coordinates past this (target units) are far off any page; such items are left out.
|
||||
const MAX_COORD: f64 = 1e6;
|
||||
/// Smallest placed bitmap side (target units), so the stretch onto it is never singular.
|
||||
const MIN_BITMAP_SIDE: f64 = 1e-6;
|
||||
|
||||
/// One item of a placed picture, in target units.
|
||||
pub enum PlacedItem {
|
||||
/// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that
|
||||
/// the renderer draws at its minimum width.
|
||||
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
|
||||
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
|
||||
Bitmap { index: usize, rect: Rect },
|
||||
/// `Picture::items[index]`, a pattern fill: `path` (target units) filled with the item's tile
|
||||
/// repeated; `tile` maps tile pixels to target units.
|
||||
Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine },
|
||||
/// `Picture::items[index]`, a text string: `transform` maps its text space to target units;
|
||||
/// `clip` (target units) bounds what it draws.
|
||||
Text { index: usize, transform: Affine, clip: Option<Rect> },
|
||||
}
|
||||
|
||||
impl Picture {
|
||||
/// Whether the picture has a finite, positive size, so it can be fitted into a frame.
|
||||
pub fn has_size(&self) -> bool {
|
||||
self.width.is_finite() && self.height.is_finite() && self.width > 0.0 && self.height > 0.0
|
||||
}
|
||||
|
||||
/// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right,
|
||||
/// bottom) removed first: the visible part fills `r`. None for a degenerate size.
|
||||
pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option<Affine> {
|
||||
let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 });
|
||||
let (w, h) = (f64::from(self.width), f64::from(self.height));
|
||||
let sx = r.width() / (w * (1.0 - cl - cr).max(0.05));
|
||||
let sy = r.height() / (h * (1.0 - ct - cb).max(0.05));
|
||||
(sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0)
|
||||
.then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy))
|
||||
}
|
||||
|
||||
/// The items placed into `r` with `crop` applied. Items that are not finite or reach past ±1e6
|
||||
/// target units are left out (they are far off any page), as are bitmaps with no area. Stroke
|
||||
/// widths are scaled to target units (along the mean axis scale, so a non-uniform fit keeps
|
||||
/// strokes even).
|
||||
pub fn place(&self, r: Rect, crop: [f32; 4]) -> Vec<PlacedItem> {
|
||||
let Some(tf) = self.fit(r, crop) else { return Vec::new() };
|
||||
let c = tf.as_coeffs();
|
||||
let k = (c[0].abs() + c[3].abs()) / 2.0;
|
||||
let mut out = Vec::with_capacity(self.items.len());
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
match item {
|
||||
Item::Path { segs, fill, stroke, even_odd } => {
|
||||
let path = tf * bez(segs);
|
||||
if in_range(path.bounding_box()) {
|
||||
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
|
||||
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd });
|
||||
}
|
||||
}
|
||||
Item::Pattern { segs, even_odd, tile: t } => {
|
||||
let path = tf * bez(segs);
|
||||
let tile = tf
|
||||
* Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1])))
|
||||
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
|
||||
let det = tile.determinant();
|
||||
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
|
||||
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile });
|
||||
}
|
||||
}
|
||||
Item::Text(t) => {
|
||||
let transform = tf * Affine::new(t.transform);
|
||||
let clip =
|
||||
t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1))));
|
||||
let c = transform.as_coeffs();
|
||||
let origin = Rect::new(c[4], c[5], c[4], c[5]);
|
||||
if in_range(origin) && c.iter().all(|v| v.is_finite()) && clip.is_none_or(in_range) {
|
||||
out.push(PlacedItem::Text { index, transform, clip });
|
||||
}
|
||||
}
|
||||
Item::Bitmap { rect: [x, y, w, h], .. } => {
|
||||
let (x0, y0) = (f64::from(*x), f64::from(*y));
|
||||
let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h)));
|
||||
if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE {
|
||||
out.push(PlacedItem::Bitmap { index, rect });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Takes a bitmap's pixels out of the item (its geometry stays) and returns them as (width, height,
|
||||
/// RGBA) when a renderer can draw them: both sides in 1..=[`MAX_BITMAP_SIDE`] and exactly that many
|
||||
/// pixels. None for paths and for bitmaps that cannot be drawn.
|
||||
pub fn take_pixels(&mut self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let Item::Bitmap { width, height, rgba, .. } = self else { return None };
|
||||
let (w, h, px) = (*width, *height, std::mem::take(rgba));
|
||||
let sides = (1..=MAX_BITMAP_SIDE).contains(&w) && (1..=MAX_BITMAP_SIDE).contains(&h);
|
||||
(sides && px.len() == (w as usize).saturating_mul(h as usize).saturating_mul(4)).then_some((w, h, px))
|
||||
}
|
||||
}
|
||||
|
||||
/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point.
|
||||
fn bez(segs: &[Seg]) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
let mut start = (0.0, 0.0);
|
||||
let mut open = false;
|
||||
for s in segs {
|
||||
match *s {
|
||||
Seg::Move(x, y) => {
|
||||
start = (f64::from(x), f64::from(y));
|
||||
p.move_to(start);
|
||||
open = true;
|
||||
}
|
||||
Seg::Line(x, y) => {
|
||||
if !open {
|
||||
p.move_to(start);
|
||||
}
|
||||
p.line_to((f64::from(x), f64::from(y)));
|
||||
open = true;
|
||||
}
|
||||
Seg::Cubic(a, b, c, d, e, f) => {
|
||||
if !open {
|
||||
p.move_to(start);
|
||||
}
|
||||
p.curve_to((f64::from(a), f64::from(b)), (f64::from(c), f64::from(d)), (f64::from(e), f64::from(f)));
|
||||
open = true;
|
||||
}
|
||||
Seg::Close => {
|
||||
if open {
|
||||
p.close_path();
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Whether a placed box is finite and within ±[`MAX_COORD`].
|
||||
fn in_range(b: Rect) -> bool {
|
||||
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() < MAX_COORD)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
//! Text records: the font (LOGFONT), the text string decoded from its character set, and the
|
||||
//! neutral [`Text`] item. The metafile crate knows no fonts, so measuring, alignment against the
|
||||
//! font's metrics and glyph drawing are left to the renderer.
|
||||
|
||||
/// A font chosen by a CREATEFONTINDIRECT record (MS-WMF 2.2.1.2 Font, MS-EMF 2.2.13 LogFont).
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Font {
|
||||
/// The face name the file asks for (`"Arial"`, `"Wingdings"`); may be empty.
|
||||
pub family: String,
|
||||
/// LOGFONT height in text units: negative is the character (em) height, positive the cell
|
||||
/// height (ascent plus descent), 0 the default size.
|
||||
pub height: f32,
|
||||
/// Average character width in text units; 0 keeps the font's own proportions.
|
||||
pub width: f32,
|
||||
/// 100..=900 (400 regular, 700 bold); 0 is the default weight.
|
||||
pub weight: u16,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
pub strike: bool,
|
||||
/// LOGFONT character set (0 ANSI, 2 SYMBOL, 238 EASTEUROPE…).
|
||||
pub charset: u8,
|
||||
}
|
||||
|
||||
/// SYMBOL_CHARSET: byte codes are the font's own symbol codes.
|
||||
pub const SYMBOL_CHARSET: u8 = 2;
|
||||
|
||||
/// Where the reference point sits on the string horizontally (TA_LEFT, TA_CENTER, TA_RIGHT).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum HAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Where the reference point sits vertically (TA_TOP, TA_BASELINE, TA_BOTTOM): the top of the
|
||||
/// character cell, the baseline or the bottom of the cell.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Baseline,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// One string drawn by EXTTEXTOUT, TEXTOUT or SMALLTEXTOUT.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Text {
|
||||
/// Text space to picture space as affine coefficients `[a, b, c, d, e, f]` (`x' = a·x + c·y + e`,
|
||||
/// `y' = b·x + d·y + f`, kurbo's order). Text space has x along the baseline and y down, in the
|
||||
/// metafile's logical units, with the reference point at the origin. Escapement and the
|
||||
/// mapping are folded in; glyphs are never mirrored.
|
||||
pub transform: [f64; 6],
|
||||
pub text: String,
|
||||
/// Advance after each character of `text`, in text units (the dx array). Empty when the file
|
||||
/// gives none: the font's own advances apply.
|
||||
pub dx: Vec<f32>,
|
||||
pub font: Font,
|
||||
pub color: [u8; 4],
|
||||
/// Colour filled behind the character cells (OPAQUE background mode); None when transparent.
|
||||
pub background: Option<[u8; 4]>,
|
||||
pub h_align: HAlign,
|
||||
pub v_align: VAlign,
|
||||
/// Clip rectangle `[x0, y0, x1, y1]` in picture units (ETO_CLIPPED).
|
||||
pub clip: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// TA_UPDATECP: the string starts at the current position, which moves past it.
|
||||
pub(crate) const TA_UPDATECP: u32 = 0x0001;
|
||||
/// ETO_OPAQUE fills the rectangle with the background colour; ETO_CLIPPED clips the text to it.
|
||||
pub(crate) const ETO_OPAQUE: u32 = 0x0002;
|
||||
pub(crate) const ETO_CLIPPED: u32 = 0x0004;
|
||||
/// The string holds glyph indices, not characters (drawn only when the font matches).
|
||||
pub(crate) const ETO_GLYPH_INDEX: u32 = 0x0010;
|
||||
/// The dx array holds (dx, dy) pairs.
|
||||
pub(crate) const ETO_PDY: u32 = 0x2000;
|
||||
/// Longest string kept from one record.
|
||||
pub(crate) const MAX_CHARS: usize = 16_384;
|
||||
|
||||
/// Horizontal and vertical alignment from a TA_* word (the vertical bits are 0 top, 8 bottom,
|
||||
/// 24 baseline; the horizontal ones 0 left, 2 right, 6 center).
|
||||
pub(crate) fn align(ta: u32) -> (HAlign, VAlign) {
|
||||
let h = match ta & 0x06 {
|
||||
6 => HAlign::Center,
|
||||
2 => HAlign::Right,
|
||||
_ => HAlign::Left,
|
||||
};
|
||||
let v = match ta & 0x18 {
|
||||
0x18 => VAlign::Baseline,
|
||||
0x08 => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
};
|
||||
(h, v)
|
||||
}
|
||||
|
||||
/// Decodes 8-bit text in `charset` to one character per byte, and merges `dx` to match: a
|
||||
/// double-byte character set's lead and trail bytes become one blank (its glyphs are not
|
||||
/// decoded) whose advance is the sum of both. `dx` may be empty.
|
||||
pub(crate) fn decode_bytes(bytes: &[u8], charset: u8, dx: &[f32]) -> (String, Vec<f32>) {
|
||||
let mut s = String::with_capacity(bytes.len());
|
||||
let mut out_dx = Vec::with_capacity(dx.len().min(bytes.len()));
|
||||
let dbcs = matches!(charset, 128 | 129 | 130 | 134 | 136);
|
||||
let mut i = 0;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
let n = if dbcs && b >= 0x81 && i + 1 < bytes.len() { 2 } else { 1 };
|
||||
let c = if n == 2 { ' ' } else { byte_char(b, charset) };
|
||||
s.push(c);
|
||||
if !dx.is_empty() {
|
||||
out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum());
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
(s, out_dx)
|
||||
}
|
||||
|
||||
/// UTF-16 text to a string with one dx entry per character: a surrogate pair takes the sum of
|
||||
/// its two advances. Unpaired surrogates become U+FFFD.
|
||||
pub(crate) fn decode_utf16(units: &[u16], dx: &[f32]) -> (String, Vec<f32>) {
|
||||
let mut s = String::with_capacity(units.len());
|
||||
let mut out_dx = Vec::with_capacity(dx.len().min(units.len()));
|
||||
let mut i = 0;
|
||||
for r in char::decode_utf16(units.iter().copied()) {
|
||||
let c = r.unwrap_or(char::REPLACEMENT_CHARACTER);
|
||||
let n = c.len_utf16();
|
||||
s.push(c);
|
||||
if !dx.is_empty() {
|
||||
out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum());
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
(s, out_dx)
|
||||
}
|
||||
|
||||
/// One byte in a single-byte Windows code page. Symbol fonts keep their codes in the private-use
|
||||
/// range (U+F000 + code), where symbol fonts map them.
|
||||
fn byte_char(b: u8, charset: u8) -> char {
|
||||
if b < 0x80 && charset != SYMBOL_CHARSET {
|
||||
return char::from(b);
|
||||
}
|
||||
let cp = match charset {
|
||||
SYMBOL_CHARSET => 0xF000 | u32::from(b),
|
||||
238 => high(b, &CP1250),
|
||||
204 => high(b, &CP1251),
|
||||
_ => high(b, &CP1252),
|
||||
};
|
||||
char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER)
|
||||
}
|
||||
|
||||
/// The code point of byte `b` (0x80..=0xFF) from a table of the upper half; 0 entries are
|
||||
/// undefined bytes, drawn as U+FFFD.
|
||||
fn high(b: u8, table: &[u16; 128]) -> u32 {
|
||||
match table.get(usize::from(b.wrapping_sub(0x80))) {
|
||||
Some(0) | None => 0xFFFD,
|
||||
Some(v) => u32::from(*v),
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows-1252 (Western) 0x80..=0xFF: the upper half of Latin-1 with typographic marks in 0x80..=0x9F.
|
||||
const CP1252: [u16; 128] = {
|
||||
let mut t = [0u16; 128];
|
||||
let low: [u16; 32] = [
|
||||
0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C,
|
||||
0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178,
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < 128 {
|
||||
t[i] = if i < 32 { low[i] } else { 0x80 + i as u16 };
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// Windows-1250 (Central European) 0x80..=0xFF.
|
||||
const CP1250: [u16; 128] = [
|
||||
0x20AC, 0, 0x201A, 0, 0x201E, 0x2026, 0x2020, 0x2021, 0, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, //
|
||||
0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, //
|
||||
0x00A0, 0x02C7, 0x02D8, 0x0141, 0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, //
|
||||
0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5, 0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, //
|
||||
0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7, 0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, //
|
||||
0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E, 0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, //
|
||||
0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB, 0x011B, 0x00ED, 0x00EE, 0x010F, //
|
||||
0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD, 0x0163, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1251 (Cyrillic) 0x80..=0xFF; 0xC0..=0xFF are А..я in order.
|
||||
const CP1251: [u16; 128] = {
|
||||
let low: [u16; 64] = [
|
||||
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, //
|
||||
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, //
|
||||
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, //
|
||||
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
|
||||
];
|
||||
let mut t = [0u16; 128];
|
||||
let mut i = 0;
|
||||
while i < 128 {
|
||||
t[i] = if i < 64 { low[i] } else { 0x0410 + (i as u16 - 64) };
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
//! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then
|
||||
//! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::bytes::Bytes;
|
||||
use crate::canvas::{BS_HATCHED, Brush, Canvas, LogFont, Obj, Pen, colorref};
|
||||
use crate::dib;
|
||||
use crate::geom::{self, ArcKind, P};
|
||||
use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, MAX_CHARS, decode_bytes};
|
||||
use crate::{Error, Frame, MAX_PATH_OPS};
|
||||
|
||||
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
|
||||
const DEFAULT_INCH: f64 = 1440.0;
|
||||
/// A placeable WMF plays into a target sized in points; brushes are laid out in 96-dpi pixels.
|
||||
const PT_PER_PX: f64 = 72.0 / 96.0;
|
||||
|
||||
pub(crate) fn is_wmf(b: Bytes) -> bool {
|
||||
if b.u32(0) == Some(PLACEABLE_KEY) {
|
||||
return true;
|
||||
}
|
||||
matches!(b.u16(0), Some(1 | 2)) && b.u16(2) == Some(9) && matches!(b.u16(4), Some(0x100 | 0x300))
|
||||
}
|
||||
|
||||
/// Reads all records. Returns the drawing state and the picture frame, or an error when no record
|
||||
/// could be read at all.
|
||||
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
|
||||
let placeable = input.u32(0) == Some(PLACEABLE_KEY);
|
||||
// Placeable bounds: logical corners (left, top, right, bottom) and their size in points.
|
||||
let (hdr, bounds) = if placeable {
|
||||
let [l, t, r, b] = [6, 8, 10, 12].map(|o| input.i16(o).map(f64::from));
|
||||
let (Some(l), Some(t), Some(r), Some(b)) = (l, t, r, b) else { return Err(Error::BadHeader) };
|
||||
let inch = input.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from);
|
||||
let (w, h) = (r - l, b - t);
|
||||
let bounds = (w > 0.0 && h > 0.0).then_some(([l, t, r, b], (w * 72.0 / inch, h * 72.0 / inch)));
|
||||
(22, bounds)
|
||||
} else {
|
||||
(0, None)
|
||||
};
|
||||
let hsize = input.u16(hdr + 2).ok_or(Error::BadHeader)? as usize;
|
||||
if hsize < 9 {
|
||||
return Err(Error::BadHeader);
|
||||
}
|
||||
let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?;
|
||||
// Played into a target like Word does: the placeable bounds are the first window, the target (the
|
||||
// picture size) the viewport. A plain WMF's target is its first window.
|
||||
let mut cv = Canvas::new();
|
||||
if let Some((c, size)) = bounds {
|
||||
cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size);
|
||||
cv.pattern_px = PT_PER_PX;
|
||||
}
|
||||
let mut player_viewport = bounds.is_some();
|
||||
let mut parsed = 0usize;
|
||||
while !cv.full() {
|
||||
let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break };
|
||||
let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { break };
|
||||
let Some(rec) = input.slice(pos, len) else { break };
|
||||
if func == 0 {
|
||||
break;
|
||||
}
|
||||
record(&mut cv, func, Bytes(rec));
|
||||
if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set {
|
||||
let we = cv.map.window_ext();
|
||||
cv.play_into(None, (we.0.abs(), we.1.abs()));
|
||||
player_viewport = true;
|
||||
}
|
||||
parsed += 1;
|
||||
let Some(next) = pos.checked_add(len) else { break };
|
||||
pos = next;
|
||||
}
|
||||
if parsed == 0 {
|
||||
return Err(Error::NoRecords);
|
||||
}
|
||||
let frame = match bounds {
|
||||
// Played into its target: the frame is the target.
|
||||
Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }),
|
||||
// The file set its own viewport, so the drawing is in its device units and the logical bounds
|
||||
// go through the final mapping.
|
||||
Some((c, size)) => {
|
||||
let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3])));
|
||||
let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
|
||||
(rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) })
|
||||
}
|
||||
None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => {
|
||||
let ve = cv.map.viewport_ext();
|
||||
Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None })
|
||||
}
|
||||
None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }),
|
||||
None => None,
|
||||
};
|
||||
Ok((cv, frame))
|
||||
}
|
||||
|
||||
fn record(cv: &mut Canvas, func: u16, r: Bytes) {
|
||||
if cv.full() {
|
||||
return;
|
||||
}
|
||||
let _ = match func {
|
||||
0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))),
|
||||
0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))),
|
||||
0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))),
|
||||
// Y is stored before X in these records.
|
||||
0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)),
|
||||
0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)),
|
||||
0x020D => xy16(r, 6).map(|p| cv.set_viewport_org(p)),
|
||||
0x020E => xy16(r, 6).map(|p| cv.set_viewport_ext(p)),
|
||||
0x001E => {
|
||||
cv.save_dc();
|
||||
Some(())
|
||||
}
|
||||
0x0127 => r.i16(6).map(|n| cv.restore_dc(i32::from(n))),
|
||||
0x0214 => xy16(r, 6).map(|p| cv.move_to(p)),
|
||||
0x0213 => xy16(r, 6).map(|p| cv.line_to(p)),
|
||||
0x0325 => counted_points(r).map(|pts| cv.poly(&pts, false, false, true)),
|
||||
0x0324 => counted_points(r).map(|pts| cv.poly(&pts, true, true, true)),
|
||||
0x0538 => polypolygon(r).map(|ops| cv.shape(&ops, true, true)),
|
||||
0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
|
||||
0x061C => {
|
||||
let [ew, eh] = [r.i16(8), r.i16(6)];
|
||||
let (Some(ew), Some(eh)) = (ew, eh) else { return };
|
||||
rect16(r, 10).map(|[l, t, rr, b]| {
|
||||
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true);
|
||||
})
|
||||
}
|
||||
0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
|
||||
0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| {
|
||||
let kind = match func {
|
||||
0x0817 => ArcKind::Arc,
|
||||
0x081A => ArcKind::Pie,
|
||||
_ => ArcKind::Chord,
|
||||
};
|
||||
let [l, t, rr, b] = bounds;
|
||||
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
|
||||
}),
|
||||
0x02FA => create_pen(cv, r),
|
||||
0x02FC => create_brush(cv, r),
|
||||
0x02FB => {
|
||||
let f = font(r).unwrap_or_default();
|
||||
cv.add_object(None, Obj::Font(Arc::new(f)));
|
||||
Some(())
|
||||
}
|
||||
0x0142 => {
|
||||
// A pattern the reader cannot decode still takes its handle slot.
|
||||
let b = dib_pattern_brush(r).map_or(Obj::Other, Obj::Brush);
|
||||
cv.add_object(None, b);
|
||||
Some(())
|
||||
}
|
||||
// Palettes, old-style pattern brushes and regions take a handle slot too, so later
|
||||
// SELECTOBJECT handles count them; selecting one draws nothing.
|
||||
0x00F7 | 0x01F9 | 0x06FF => {
|
||||
cv.add_object(None, Obj::Other);
|
||||
Some(())
|
||||
}
|
||||
0x0209 => r.u32(6).map(|c| cv.set_text_color(c)),
|
||||
0x0201 => r.u32(6).map(|c| cv.set_bk_color(c)),
|
||||
0x0102 => r.u16(6).map(|m| cv.set_bk_mode(u32::from(m))),
|
||||
0x012E => r.u16(6).map(|a| cv.set_text_align(u32::from(a))),
|
||||
0x0A32 => ext_text_out(cv, r),
|
||||
0x0521 => text_out(cv, r),
|
||||
// PATBLT: rop(4), height, width, y, x.
|
||||
0x061D => r.u32(6).map(|rop| cv.fill_rop([i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)], rop)),
|
||||
0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))),
|
||||
0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))),
|
||||
// STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB.
|
||||
0x0F43 => {
|
||||
let rop = r.u32(6).unwrap_or(0);
|
||||
dib_blit(cv, r, 28, rop, [i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], [i16s(r, 18), i16s(r, 16), i16s(r, 14), i16s(r, 12)])
|
||||
}
|
||||
0x0B41 => dib_stretchblt(cv, r),
|
||||
0x0940 => dib_bitblt(cv, r),
|
||||
_ => Some(()),
|
||||
};
|
||||
}
|
||||
|
||||
/// A 16-bit WMF handle as a canvas object index: stock objects are `0x8000 | index`, which the
|
||||
/// canvas reads with the 32-bit stock flag.
|
||||
fn wmf_handle(h: u16) -> u32 {
|
||||
let h = u32::from(h);
|
||||
if h & 0x8000 != 0 { (h & 0x7FFF) | 0x8000_0000 } else { h }
|
||||
}
|
||||
|
||||
/// An i16 field as f64; 0 past the end.
|
||||
fn i16s(r: Bytes, off: usize) -> f64 {
|
||||
r.i16(off).map_or(0.0, f64::from)
|
||||
}
|
||||
|
||||
/// CREATEPENINDIRECT: style, width (x, y), colour. Only the width's x component is used.
|
||||
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let style = r.u16(6)?;
|
||||
let width = r.i16(8)?;
|
||||
let color = r.u32(12)?;
|
||||
cv.add_object(None, Obj::Pen(Pen { style: u32::from(style), width: f64::from(width), color: colorref(color) }));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// CREATEBRUSHINDIRECT: style, colour, hatch.
|
||||
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let style = u32::from(r.u16(6)?);
|
||||
let color = r.u32(8)?;
|
||||
let mut b = Brush::new(style, colorref(color));
|
||||
if style == BS_HATCHED {
|
||||
b.hatch = u32::from(r.u16(12).unwrap_or(0));
|
||||
}
|
||||
cv.add_object(None, Obj::Brush(b));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// DIBCREATEPATTERNBRUSH: style, colour usage, then a DIB (MS-WMF 2.3.4.8). The DIB's own palette
|
||||
/// gives the colours; with DIB_PAL_COLORS (indices into a palette the reader does not keep) a
|
||||
/// monochrome DIB takes the text and background colours.
|
||||
fn dib_pattern_brush(r: Bytes) -> Option<Brush> {
|
||||
let usage = r.u16(8)?;
|
||||
let dib = r.tail(10)?;
|
||||
let n = dib::info_len(dib)?;
|
||||
let img = dib::decode(Bytes(dib.slice(0, n)?), dib.tail(n)?, false)?;
|
||||
Brush::pattern(img, usage == 1)
|
||||
}
|
||||
|
||||
/// Font object (MS-WMF 2.2.1.2): height, width, escapement, orientation, weight (i16 each), italic,
|
||||
/// underline, strike-out and charset bytes, three precision/quality bytes, pitch and family, then the
|
||||
/// face name (up to 32 bytes, NUL-terminated, in the font's character set).
|
||||
fn font(r: Bytes) -> Option<LogFont> {
|
||||
let height = r.i16(6)?;
|
||||
let width = r.i16(8).unwrap_or(0);
|
||||
let escapement = r.i16(10).unwrap_or(0);
|
||||
let weight = r.i16(14).unwrap_or(0);
|
||||
let byte = |o: usize| r.0.get(o).copied().unwrap_or(0);
|
||||
let charset = byte(19);
|
||||
let name = r.0.get(24..).unwrap_or(&[]);
|
||||
let name = name.get(..32).unwrap_or(name);
|
||||
let name = name.split(|b| *b == 0).next().unwrap_or(&[]);
|
||||
// Face names are plain ASCII in practice; other bytes go through the Western code page.
|
||||
let (family, _) = decode_bytes(name, 0, &[]);
|
||||
Some(LogFont {
|
||||
font: Font {
|
||||
family: family.trim().to_string(),
|
||||
height: f32::from(height),
|
||||
width: f32::from(width),
|
||||
weight: weight.clamp(0, 1000) as u16,
|
||||
italic: byte(16) != 0,
|
||||
underline: byte(17) != 0,
|
||||
strike: byte(18) != 0,
|
||||
charset,
|
||||
},
|
||||
escapement: f64::from(escapement),
|
||||
})
|
||||
}
|
||||
|
||||
/// The charset of the selected font, for decoding 8-bit text.
|
||||
fn charset(cv: &Canvas) -> u8 {
|
||||
cv.font_charset()
|
||||
}
|
||||
|
||||
/// EXTTEXTOUT (MS-WMF 2.3.3.5): y, x, string length, options, the rectangle (left, top, right,
|
||||
/// bottom) when ETO_OPAQUE or ETO_CLIPPED is set, the string padded to a word, then an optional dx
|
||||
/// array of i16 advances.
|
||||
fn ext_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let (y, x) = (r.i16(6)?, r.i16(8)?);
|
||||
let n = usize::from(r.u16(10)?).min(MAX_CHARS);
|
||||
let opts = u32::from(r.u16(12)?);
|
||||
let has_rect = opts & (ETO_OPAQUE | ETO_CLIPPED) != 0;
|
||||
let rect = if has_rect { Some([i16s(r, 14), i16s(r, 16), i16s(r, 18), i16s(r, 20)]) } else { None };
|
||||
let at = if has_rect { 22 } else { 14 };
|
||||
// A string running past the record is cut to what the record holds.
|
||||
let avail = r.0.len().saturating_sub(at);
|
||||
let n = n.min(avail);
|
||||
let bytes = r.slice(at, n)?;
|
||||
let dx_at = at + n.next_multiple_of(2);
|
||||
let dx: Vec<f32> = if r.0.len() >= dx_at + 2 * n { (0..n).filter_map(|i| r.i16(dx_at + 2 * i)).map(f32::from).collect() } else { Vec::new() };
|
||||
let (text, dx) = decode_bytes(bytes, charset(cv), &dx);
|
||||
cv.text((f64::from(x), f64::from(y)), text, dx, opts, rect);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// TEXTOUT (MS-WMF 2.3.3.20): string length, the string padded to a word, then y and x.
|
||||
fn text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let n = usize::from(r.u16(6)?).min(MAX_CHARS);
|
||||
let bytes = r.slice(8, n)?;
|
||||
let at = 8 + n.next_multiple_of(2);
|
||||
let (y, x) = (r.i16(at)?, r.i16(at + 2)?);
|
||||
let (text, _) = decode_bytes(bytes, charset(cv), &[]);
|
||||
cv.text((f64::from(x), f64::from(y)), text, Vec::new(), 0, None);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// (x, y) from a Y-then-X pair stored at `off`.
|
||||
fn xy16(r: Bytes, off: usize) -> Option<P> {
|
||||
Some((f64::from(r.i16(off + 2)?), f64::from(r.i16(off)?)))
|
||||
}
|
||||
|
||||
/// Rectangle stored as bottom, right, top, left (the order MS-WMF uses for RECTANGLE and ELLIPSE).
|
||||
fn rect16(r: Bytes, off: usize) -> Option<[f64; 4]> {
|
||||
let (b, rr, t, l) = (r.i16(off)?, r.i16(off + 2)?, r.i16(off + 4)?, r.i16(off + 6)?);
|
||||
Some([f64::from(l), f64::from(t), f64::from(rr), f64::from(b)])
|
||||
}
|
||||
|
||||
/// ARC, PIE and CHORD: end Y, end X, start Y, start X, then the bounding rectangle (bottom, right, top, left).
|
||||
fn arc16(r: Bytes) -> Option<([f64; 4], P, P)> {
|
||||
let end = xy16(r, 6)?;
|
||||
let start = xy16(r, 10)?;
|
||||
let bounds = rect16(r, 14)?;
|
||||
Some((bounds, start, end))
|
||||
}
|
||||
|
||||
/// A count of 16-bit points, then the points (POLYLINE, POLYGON).
|
||||
fn counted_points(r: Bytes) -> Option<Vec<P>> {
|
||||
let n = r.u16(6)? as usize;
|
||||
r.tail(8)?.points(0, n, false)
|
||||
}
|
||||
|
||||
/// POLYPOLYGON: polygon count, one 16-bit point count per polygon, then all points.
|
||||
fn polypolygon(r: Bytes) -> Option<Vec<geom::Op>> {
|
||||
let n = r.u16(6)? as usize;
|
||||
let mut counts = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
counts.push(r.u16(i.checked_mul(2)?.checked_add(8)?)? as usize);
|
||||
}
|
||||
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
|
||||
if total > MAX_PATH_OPS {
|
||||
return None;
|
||||
}
|
||||
let pts = r.tail(n.checked_mul(2)?.checked_add(8)?)?.points(0, total, false)?;
|
||||
Some(geom::subpaths(&pts, &counts, true))
|
||||
}
|
||||
|
||||
/// Whether a DIBBITBLT or DIBSTRETCHBLT record carries a bitmap: without one it is exactly
|
||||
/// `(function >> 8) + 3` words long (MS-WMF 2.3.1.2, 2.3.1.3).
|
||||
fn has_bitmap(func: u16, r: Bytes) -> bool {
|
||||
r.0.len() / 2 > usize::from(func >> 8) + 3
|
||||
}
|
||||
|
||||
/// META_DIBBITBLT: rop(4), ySrc, xSrc, then height, width, yDest, xDest and the DIB at 22. The
|
||||
/// bitmap-less form has a reserved word before the height, so its fields sit 2 bytes later; it, or
|
||||
/// a source-less raster operation, fills the destination.
|
||||
fn dib_bitblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let rop = r.u32(6)?;
|
||||
if !has_bitmap(0x0940, r) {
|
||||
cv.fill_rop([i16s(r, 22), i16s(r, 20), i16s(r, 18), i16s(r, 16)], rop);
|
||||
return Some(());
|
||||
}
|
||||
let dst = [i16s(r, 20), i16s(r, 18), i16s(r, 16), i16s(r, 14)];
|
||||
if Canvas::sourceless(rop) {
|
||||
cv.fill_rop(dst, rop);
|
||||
return Some(());
|
||||
}
|
||||
let src = [i16s(r, 12), i16s(r, 10), dst[2], dst[3]];
|
||||
dib_blit(cv, r, 22, rop, dst, src)
|
||||
}
|
||||
|
||||
/// META_DIBSTRETCHBLT: rop(4), srcH, srcW, ySrc, xSrc, then destH, destW, yDest, xDest and the DIB at
|
||||
/// 26. The bitmap-less form has a reserved word after xSrc, so the destination sits 2 bytes later;
|
||||
/// it, or a source-less raster operation, fills the destination.
|
||||
fn dib_stretchblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let rop = r.u32(6)?;
|
||||
if !has_bitmap(0x0B41, r) {
|
||||
cv.fill_rop([i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], rop);
|
||||
return Some(());
|
||||
}
|
||||
let dst = [i16s(r, 24), i16s(r, 22), i16s(r, 20), i16s(r, 18)];
|
||||
if Canvas::sourceless(rop) {
|
||||
cv.fill_rop(dst, rop);
|
||||
return Some(());
|
||||
}
|
||||
let src = [i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)];
|
||||
dib_blit(cv, r, 26, rop, dst, src)
|
||||
}
|
||||
|
||||
/// A DIB at `at`, stretched from source rectangle `src` into `dst` (both in logical units, pixels for `src`).
|
||||
/// The raster operation `rop` decides how the pixels combine (see [`dib::prepare`]).
|
||||
fn dib_blit(cv: &mut Canvas, r: Bytes, at: usize, rop: u32, dst: [f64; 4], src: [f64; 4]) -> Option<()> {
|
||||
let dib = r.tail(at)?;
|
||||
let n = dib::info_len(dib)?;
|
||||
let info = dib.slice(0, n)?;
|
||||
let bits = dib.tail(n)?;
|
||||
let mut img = dib::decode(Bytes(info), bits, false)?;
|
||||
dib::prepare(&mut img, dib::Blend::Rop(rop));
|
||||
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
|
||||
cv.bitmap(dst, img);
|
||||
Some(())
|
||||
}
|
||||
@@ -14,3 +14,4 @@ deckcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
|
||||
@@ -243,6 +243,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
});
|
||||
};
|
||||
@@ -511,6 +512,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
|
||||
first_slide_number: 1,
|
||||
next_id: next.max(256),
|
||||
embedded_fonts: vec![],
|
||||
table_styles: vec![],
|
||||
raw_ext: None,
|
||||
};
|
||||
if title_slide {
|
||||
|
||||
+248
-45
@@ -2,6 +2,7 @@
|
||||
//! with `\n` splits paragraphs; formatting a range splits runs at its ends.
|
||||
|
||||
use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
|
||||
use crate::words;
|
||||
|
||||
/// (paragraph, char offset in the paragraph).
|
||||
pub type Pos = (usize, usize);
|
||||
@@ -275,65 +276,109 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
caret
|
||||
}
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_' || c == '\''
|
||||
fn para_text(body: &TextBody, pi: usize) -> String {
|
||||
body.paragraphs.get(pi).map(Paragraph::text).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Word boundaries around `pos` (double-click selection).
|
||||
/// The word a double-click selects around `pos` (see [`words::word_at`]): the word without its
|
||||
/// trailing spaces, as PowerPoint selects it.
|
||||
pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
|
||||
if t.is_empty() {
|
||||
return ((pi, 0), (pi, 0));
|
||||
}
|
||||
let at = ch.min(t.len().saturating_sub(1));
|
||||
let w = t.get(at).copied().is_some_and(is_word);
|
||||
let mut s = at;
|
||||
while s > 0 && t.get(s - 1).copied().is_some_and(|c| is_word(c) == w && !c.is_whitespace() || (w && is_word(c))) {
|
||||
s -= 1;
|
||||
}
|
||||
let mut e = at;
|
||||
while e < t.len() && t.get(e).copied().is_some_and(|c| is_word(c) == w && (w || !c.is_whitespace())) {
|
||||
e += 1;
|
||||
}
|
||||
// Include trailing spaces after a word.
|
||||
while w && e < t.len() && t.get(e) == Some(&' ') {
|
||||
e += 1;
|
||||
}
|
||||
((pi, s), (pi, e.max(s)))
|
||||
let (s, e) = words::word_at(¶_text(body, pi), ch);
|
||||
((pi, s), (pi, e))
|
||||
}
|
||||
|
||||
/// Next/previous word start (Option/Ctrl + arrows).
|
||||
/// Next/previous word stop (Ctrl+Right/Left on Windows, Option+arrows on the Mac). At the end of
|
||||
/// a paragraph Ctrl+Right goes to the start of the next one; at its start Ctrl+Left goes to the
|
||||
/// end of the previous one.
|
||||
pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
|
||||
let t = para_text(body, pi);
|
||||
if forward {
|
||||
if ch >= t.len() {
|
||||
return if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, ch) };
|
||||
match words::next_stop(&t, ch) {
|
||||
Some(s) => (pi, s),
|
||||
None if pi + 1 < body.paragraphs.len() => (pi + 1, 0),
|
||||
None => (pi, ch),
|
||||
}
|
||||
let mut i = ch;
|
||||
while i < t.len() && t.get(i).copied().is_some_and(is_word) {
|
||||
i += 1;
|
||||
}
|
||||
while i < t.len() && !t.get(i).copied().is_some_and(is_word) {
|
||||
i += 1;
|
||||
}
|
||||
(pi, i)
|
||||
} else {
|
||||
if ch == 0 {
|
||||
return if pi > 0 { (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)) } else { (0, 0) };
|
||||
match words::prev_stop(&t, ch) {
|
||||
Some(s) => (pi, s),
|
||||
None if pi > 0 => (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)),
|
||||
None => (pi, 0),
|
||||
}
|
||||
let mut i = ch;
|
||||
while i > 0 && !t.get(i - 1).copied().is_some_and(is_word) {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && t.get(i - 1).copied().is_some_and(is_word) {
|
||||
i -= 1;
|
||||
}
|
||||
(pi, i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+Up: the start of the paragraph, or of the previous one when already there.
|
||||
pub fn para_up(body: &TextBody, pos: Pos) -> Pos {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
if ch > 0 { (pi, 0) } else { (pi.saturating_sub(1), 0) }
|
||||
}
|
||||
|
||||
/// Ctrl+Down: the start of the next paragraph, or the end of the last one.
|
||||
pub fn para_down(body: &TextBody, pos: Pos) -> Pos {
|
||||
let (pi, _) = clamp(body, pos);
|
||||
if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0)) }
|
||||
}
|
||||
|
||||
/// Delete a word from `pos` as Ctrl+Backspace (`forward == false`) or Ctrl+Delete does in
|
||||
/// PowerPoint; returns the caret.
|
||||
///
|
||||
/// Ctrl+Backspace deletes back to the previous word stop (at a paragraph's start it joins the
|
||||
/// paragraph to the one before). Ctrl+Delete deletes the rest of the word, or the spaces after a
|
||||
/// word and the next word, or up to the next stop (see [`words::delete_forward_end`]), then
|
||||
/// tidies the spacing as PowerPoint's smart cut does: spaces left before closing punctuation or
|
||||
/// at the end of the paragraph after a word go, and sentence punctuation left against a word
|
||||
/// gets a space. At a paragraph's end it joins the next paragraph.
|
||||
pub fn delete_word(body: &mut TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
if body.paragraphs.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
let t = para_text(body, pi);
|
||||
if !forward {
|
||||
let to = match words::prev_stop(&t, ch) {
|
||||
Some(s) => (pi, s),
|
||||
None => char_move(body, (pi, ch), false),
|
||||
};
|
||||
return delete(body, to, (pi, ch));
|
||||
}
|
||||
let Some(e) = words::delete_forward_end(&t, ch) else {
|
||||
let to = char_move(body, (pi, ch), true);
|
||||
return delete(body, (pi, ch), to);
|
||||
};
|
||||
let at = delete(body, (pi, ch), (pi, e));
|
||||
smart_space(body, at)
|
||||
}
|
||||
|
||||
/// PowerPoint's smart-cut spacing after a word was deleted at `at`; returns the caret.
|
||||
fn smart_space(body: &mut TextBody, at: Pos) -> Pos {
|
||||
let (pi, s) = at;
|
||||
let chars: Vec<char> = para_text(body, pi).chars().collect();
|
||||
let after = chars.get(s).copied();
|
||||
let before = s.checked_sub(1).and_then(|i| chars.get(i)).copied();
|
||||
if after.is_none_or(words::is_closing_punct) && before.is_some_and(|c| c == ' ') {
|
||||
let mut w = s;
|
||||
while w > 0 && chars.get(w - 1) == Some(&' ') {
|
||||
w -= 1;
|
||||
}
|
||||
if w > 0 && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric()) {
|
||||
return delete(body, (pi, w), (pi, s));
|
||||
}
|
||||
return at;
|
||||
}
|
||||
if let (Some(b), Some(a)) = (before, after)
|
||||
&& words::is_sentence_punct(b)
|
||||
&& a.is_alphanumeric()
|
||||
{
|
||||
let digits = s >= 2 && chars.get(s - 2).is_some_and(|c| c.is_numeric()) && a.is_numeric();
|
||||
if !digits {
|
||||
return insert(body, at, " ", None);
|
||||
}
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// One character left/right across paragraph boundaries.
|
||||
pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
let (pi, ch) = clamp(body, pos);
|
||||
@@ -361,6 +406,18 @@ pub fn end(body: &TextBody) -> Pos {
|
||||
(pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The case Shift+F3 changes `s` to next, as PowerPoint cycles it: lowercase → Capitalize Each
|
||||
/// Word → UPPERCASE → lowercase; any other mix goes to UPPERCASE first.
|
||||
pub fn next_case(s: &str) -> &'static str {
|
||||
if s == s.to_lowercase() && s.chars().any(char::is_alphabetic) {
|
||||
"title"
|
||||
} else if s == s.to_uppercase() && s.chars().any(char::is_alphabetic) {
|
||||
"lower"
|
||||
} else {
|
||||
"upper"
|
||||
}
|
||||
}
|
||||
|
||||
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
||||
pub fn change_case(s: &str, mode: &str) -> String {
|
||||
match mode {
|
||||
@@ -456,7 +513,7 @@ mod tests {
|
||||
#[test]
|
||||
fn words_and_moves() {
|
||||
let b = body("Hello brave world");
|
||||
assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 12)));
|
||||
assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 11)));
|
||||
assert_eq!(word_move(&b, (0, 0), true), (0, 6));
|
||||
assert_eq!(word_move(&b, (0, 8), false), (0, 6));
|
||||
assert_eq!(char_move(&b, (0, 17), true), (0, 17));
|
||||
@@ -472,4 +529,150 @@ mod tests {
|
||||
assert_eq!(b.paragraphs[0].char_len(), 3);
|
||||
assert_eq!(b.paragraphs.len(), 1);
|
||||
}
|
||||
|
||||
/// Ctrl+Backspace / Ctrl+Delete from `at`: the text and caret after (PowerPoint's results).
|
||||
fn del_word(text: &str, at: Pos, forward: bool) -> (String, Pos) {
|
||||
let mut b = body(text);
|
||||
let c = delete_word(&mut b, at, forward);
|
||||
(b.text(), c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_backspace_like_powerpoint() {
|
||||
let t = "ab cd...ef gh";
|
||||
let want = [
|
||||
("ab cd...ef gh", 0),
|
||||
("b cd...ef gh", 0),
|
||||
(" cd...ef gh", 0),
|
||||
(" cd...ef gh", 0),
|
||||
(" cd...ef gh", 0),
|
||||
("cd...ef gh", 0),
|
||||
("ab d...ef gh", 5),
|
||||
("ab ...ef gh", 5),
|
||||
("ab cd..ef gh", 7),
|
||||
("ab cd.ef gh", 7),
|
||||
("ab cdef gh", 7),
|
||||
("ab cd...f gh", 10),
|
||||
("ab cd... gh", 10),
|
||||
("ab cd...gh", 10),
|
||||
("ab cd...ef h", 13),
|
||||
("ab cd...ef ", 13),
|
||||
];
|
||||
for (c, (txt, caret)) in want.iter().enumerate() {
|
||||
assert_eq!(del_word(t, (0, c), false), (txt.to_string(), (0, *caret)), "caret {c}");
|
||||
}
|
||||
let w = "Hello world foo, bar\nnext para";
|
||||
assert_eq!(del_word(w, (0, 12), false), ("Hello foo, bar\nnext para".into(), (0, 6)));
|
||||
assert_eq!(del_word(w, (0, 17), false), ("Hello world foo bar\nnext para".into(), (0, 16)));
|
||||
// At a paragraph's start it joins the paragraph before.
|
||||
assert_eq!(del_word(w, (1, 0), false), ("Hello world foo, barnext para".into(), (0, 21)));
|
||||
assert_eq!(del_word("ab\n cd", (1, 2), false), ("ab\ncd".into(), (1, 0)));
|
||||
// A line break is a word of its own.
|
||||
let mut b = body("ab");
|
||||
insert(&mut b, (0, 2), "\u{b}cd", None);
|
||||
assert_eq!(delete_word(&mut b, (0, 3), false), (0, 2));
|
||||
assert_eq!(b.text(), "abcd");
|
||||
let mut b = body("ab");
|
||||
insert(&mut b, (0, 2), "\u{b}cd", None);
|
||||
assert_eq!(delete_word(&mut b, (0, 2), true), (0, 2));
|
||||
assert_eq!(b.text(), "abcd");
|
||||
assert_eq!(del_word("x", (0, 0), false), ("x".into(), (0, 0)));
|
||||
// Emoji sequences and combining marks are never split.
|
||||
assert_eq!(del_word("a 👨\u{200d}👩\u{200d}👧", (0, 7), false), ("a ".into(), (0, 2)));
|
||||
assert_eq!(del_word("ce\u{301}te x", (0, 5), false), (" x".into(), (0, 0)));
|
||||
assert_eq!(del_word("ce\u{301}te x", (0, 6), false), ("x".into(), (0, 0)));
|
||||
// Each CJK character is a word.
|
||||
assert_eq!(del_word("日本語", (0, 3), false), ("日本".into(), (0, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_delete_like_powerpoint() {
|
||||
let t = "ab cd...ef gh";
|
||||
let want = [
|
||||
("cd...ef gh", 0),
|
||||
("a cd...ef gh", 1),
|
||||
("ab...ef gh", 2),
|
||||
("ab cd...ef gh", 3),
|
||||
("ab cd...ef gh", 4),
|
||||
("ab...ef gh", 2),
|
||||
("ab c...ef gh", 6),
|
||||
("ab cdef gh", 7),
|
||||
("ab cd. ef gh", 9),
|
||||
("ab cd.. ef gh", 10),
|
||||
("ab cd... gh", 11),
|
||||
("ab cd...e gh", 11),
|
||||
("ab cd...ef", 12),
|
||||
("ab cd...ef", 12),
|
||||
("ab cd...ef g", 14),
|
||||
("ab cd...ef gh", 15),
|
||||
];
|
||||
for (c, (txt, caret)) in want.iter().enumerate() {
|
||||
assert_eq!(del_word(t, (0, c), true), (txt.to_string(), (0, *caret)), "caret {c}");
|
||||
}
|
||||
let w = "Hello world foo, bar\nnext para";
|
||||
let want = [
|
||||
(0, "world foo, bar\nnext para", 0),
|
||||
(2, "He world foo, bar\nnext para", 2),
|
||||
(5, "Hello foo, bar\nnext para", 5),
|
||||
(6, "Hello foo, bar\nnext para", 6),
|
||||
(8, "Hello wo foo, bar\nnext para", 8),
|
||||
(11, "Hello world, bar\nnext para", 11),
|
||||
(12, "Hello world foo, bar\nnext para", 12),
|
||||
(16, "Hello world foobar\nnext para", 16),
|
||||
(17, "Hello world foo,\nnext para", 17),
|
||||
(18, "Hello world foo, \nnext para", 18),
|
||||
(21, "Hello world foo, barnext para", 21),
|
||||
];
|
||||
for (c, txt, caret) in want {
|
||||
assert_eq!(del_word(w, (0, c), true), (txt.to_string(), (0, caret)), "caret {c}");
|
||||
}
|
||||
assert_eq!(del_word(w, (1, 0), true), ("Hello world foo, bar\npara".into(), (1, 0)));
|
||||
assert_eq!(del_word(w, (1, 2), true), ("Hello world foo, bar\nne para".into(), (1, 2)));
|
||||
assert_eq!(del_word("ab \ncd", (0, 2), true), ("ab\ncd".into(), (0, 2)));
|
||||
assert_eq!(del_word("ab \ncd", (0, 3), true), ("ab\ncd".into(), (0, 2)));
|
||||
assert_eq!(del_word("ab\t cd", (0, 2), true), ("ab cd".into(), (0, 2)));
|
||||
assert_eq!(del_word("ab cd(x) ef", (0, 3), true), ("ab (x) ef".into(), (0, 3)));
|
||||
assert_eq!(del_word("ab cd, ef", (0, 3), true), ("ab, ef".into(), (0, 2)));
|
||||
assert_eq!(del_word("ab. (cd) ef", (0, 5), true), ("ab. () ef".into(), (0, 5)));
|
||||
assert_eq!(del_word("ab \"cd\" ef", (0, 4), true), ("ab \"\" ef".into(), (0, 4)));
|
||||
assert_eq!(del_word("ab, cd", (0, 4), true), ("ab, ".into(), (0, 4)));
|
||||
// Right-to-left text deletes in logical order like any other.
|
||||
assert_eq!(del_word("مرحبا بالعالم hello", (0, 6), true), ("مرحبا hello".into(), (0, 6)));
|
||||
assert_eq!(del_word("مرحبا بالعالم hello", (0, 8), false), ("مرحبا لعالم hello".into(), (0, 6)));
|
||||
// Numbers keep their separators: no space is pushed into "3.14".
|
||||
assert_eq!(del_word("3.x 14", (0, 2), true), ("3.14".into(), (0, 2)));
|
||||
// Hostile positions clamp.
|
||||
assert_eq!(del_word("ab", (9, 9), true).0, "ab");
|
||||
assert_eq!(del_word("", (0, 0), false).0, "");
|
||||
let mut empty = TextBody::default();
|
||||
assert_eq!(delete_word(&mut empty, (3, 3), true), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_moves() {
|
||||
let b = body("one two\nthree\n\nfour");
|
||||
assert_eq!(para_down(&b, (0, 2)), (1, 0));
|
||||
assert_eq!(para_down(&b, (1, 0)), (2, 0));
|
||||
assert_eq!(para_down(&b, (3, 1)), (3, 4));
|
||||
assert_eq!(para_up(&b, (3, 2)), (3, 0));
|
||||
assert_eq!(para_up(&b, (3, 0)), (2, 0));
|
||||
assert_eq!(para_up(&b, (0, 0)), (0, 0));
|
||||
assert_eq!(word_move(&b, (0, 4), true), (0, 7));
|
||||
assert_eq!(word_move(&b, (0, 7), true), (1, 0));
|
||||
assert_eq!(word_move(&b, (1, 0), false), (0, 7));
|
||||
assert_eq!(word_move(&b, (2, 0), true), (3, 0));
|
||||
assert_eq!(word_move(&b, (3, 4), true), (3, 4));
|
||||
assert_eq!(word_move(&b, (0, 0), false), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_cycle() {
|
||||
assert_eq!(next_case("hello world"), "title");
|
||||
assert_eq!(next_case("Hello World"), "upper");
|
||||
assert_eq!(next_case("HELLO WORLD"), "lower");
|
||||
assert_eq!(next_case("Hello world"), "upper");
|
||||
assert_eq!(next_case("hELLO wORLD"), "upper");
|
||||
assert_eq!(next_case("hello. world is HERE"), "upper");
|
||||
assert_eq!(next_case("123"), "upper");
|
||||
}
|
||||
}
|
||||
|
||||
+79
-15
@@ -16,6 +16,7 @@ pub mod style;
|
||||
pub mod table;
|
||||
pub mod text;
|
||||
pub mod theme;
|
||||
pub mod words;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -443,6 +444,46 @@ pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> {
|
||||
rec(shapes, id, 0)
|
||||
}
|
||||
|
||||
/// The list holding `id` among its siblings: the tree's top level, or a group's children.
|
||||
pub fn sibling_list_mut(shapes: &mut Vec<Shape>, id: ShapeId) -> Option<&mut Vec<Shape>> {
|
||||
fn rec(shapes: &mut Vec<Shape>, id: ShapeId, depth: usize) -> Option<&mut Vec<Shape>> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
if shapes.iter().any(|s| s.id == id) {
|
||||
return Some(shapes);
|
||||
}
|
||||
for s in shapes.iter_mut() {
|
||||
if let ShapeKind::Group { children, .. } = &mut s.kind
|
||||
&& let Some(f) = rec(children, id, depth + 1)
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(shapes, id, 0)
|
||||
}
|
||||
|
||||
/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent.
|
||||
pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option<Option<ShapeId>> {
|
||||
fn rec(shapes: &[Shape], parent: Option<ShapeId>, id: ShapeId, depth: usize) -> Option<Option<ShapeId>> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for s in shapes {
|
||||
if s.id == id {
|
||||
return Some(parent);
|
||||
}
|
||||
if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(shapes, None, id, 0)
|
||||
}
|
||||
|
||||
/// Background of a slide, layout or master.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
@@ -488,6 +529,10 @@ pub struct Slide {
|
||||
/// Speaker notes.
|
||||
pub notes: TextBody,
|
||||
pub comments: Vec<Comment>,
|
||||
/// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows
|
||||
/// the layout.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_map: Option<Vec<(String, String)>>,
|
||||
/// Unmodelled XML kept for round-trip (custom data, tags).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
@@ -507,6 +552,7 @@ impl Default for Slide {
|
||||
animations: vec![],
|
||||
notes: TextBody::default(),
|
||||
comments: vec![],
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
}
|
||||
}
|
||||
@@ -596,6 +642,10 @@ pub struct Layout {
|
||||
pub background: Option<Background>,
|
||||
pub show_master_shapes: bool,
|
||||
pub preserve: bool,
|
||||
/// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it
|
||||
/// (dark "section" layouts map `bg1` to `dk1`); None follows the master.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_map: Option<Vec<(String, String)>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
}
|
||||
@@ -641,24 +691,17 @@ impl Master {
|
||||
}
|
||||
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map.
|
||||
pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot {
|
||||
let key = match slot {
|
||||
SchemeSlot::Bg1 => "bg1",
|
||||
SchemeSlot::Tx1 => "tx1",
|
||||
SchemeSlot::Bg2 => "bg2",
|
||||
SchemeSlot::Tx2 => "tx2",
|
||||
other => return other,
|
||||
};
|
||||
self.color_map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
|
||||
SchemeSlot::Bg1 => SchemeSlot::Lt1,
|
||||
SchemeSlot::Tx1 => SchemeSlot::Dk1,
|
||||
SchemeSlot::Bg2 => SchemeSlot::Lt2,
|
||||
_ => SchemeSlot::Dk2,
|
||||
})
|
||||
map_slot(&self.color_map, slot)
|
||||
}
|
||||
/// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours).
|
||||
pub fn scheme(&self) -> ColorScheme {
|
||||
self.scheme_with(&self.color_map)
|
||||
}
|
||||
/// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own)
|
||||
/// applied.
|
||||
pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme {
|
||||
let mut s = self.theme.colors.clone();
|
||||
if self.color_map.is_empty() {
|
||||
if map.is_empty() {
|
||||
return s;
|
||||
}
|
||||
let base = self.theme.colors.clone();
|
||||
@@ -668,12 +711,30 @@ impl Master {
|
||||
(SchemeSlot::Bg2, SchemeSlot::Lt2),
|
||||
(SchemeSlot::Tx2, SchemeSlot::Dk2),
|
||||
] {
|
||||
s.set(slot, base.get(self.map_slot(alias)));
|
||||
s.set(slot, base.get(map_slot(map, alias)));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has
|
||||
/// no entry).
|
||||
pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot {
|
||||
let key = match slot {
|
||||
SchemeSlot::Bg1 => "bg1",
|
||||
SchemeSlot::Tx1 => "tx1",
|
||||
SchemeSlot::Bg2 => "bg2",
|
||||
SchemeSlot::Tx2 => "tx2",
|
||||
other => return other,
|
||||
};
|
||||
map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
|
||||
SchemeSlot::Bg1 => SchemeSlot::Lt1,
|
||||
SchemeSlot::Tx1 => SchemeSlot::Dk1,
|
||||
SchemeSlot::Bg2 => SchemeSlot::Lt2,
|
||||
_ => SchemeSlot::Dk2,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Section {
|
||||
@@ -778,6 +839,9 @@ pub struct Presentation {
|
||||
pub next_id: u32,
|
||||
/// Embedded fonts (family names; bytes in `media`).
|
||||
pub embedded_fonts: Vec<(String, MediaId)>,
|
||||
/// Table styles defined in the file (`ppt/tableStyles.xml`).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub table_styles: Vec<table::TableStyleDef>,
|
||||
/// Unmodelled presentation-level XML kept for round-trip.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
|
||||
+41
-10
@@ -22,23 +22,27 @@ pub struct Ctx<'a> {
|
||||
pub layout: Option<&'a Layout>,
|
||||
pub owner: Owner,
|
||||
pub scheme: ColorScheme,
|
||||
/// The colour map in force: the slide's override, else its layout's, else the master's.
|
||||
pub color_map: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub fn for_slide(pres: &'a Presentation, slide: &Slide) -> Option<Ctx<'a>> {
|
||||
let (master, layout) = pres.master_for(slide)?;
|
||||
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme() })
|
||||
let map = slide.color_map.as_ref().or(layout.and_then(|l| l.color_map.as_ref())).unwrap_or(&master.color_map).clone();
|
||||
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme_with(&map), color_map: map })
|
||||
}
|
||||
pub fn for_layout(pres: &'a Presentation, master: &'a Master, layout: &'a Layout) -> Ctx<'a> {
|
||||
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme() }
|
||||
let map = layout.color_map.as_ref().unwrap_or(&master.color_map).clone();
|
||||
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme_with(&map), color_map: map }
|
||||
}
|
||||
pub fn for_master(pres: &'a Presentation, master: &'a Master) -> Ctx<'a> {
|
||||
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme() }
|
||||
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme(), color_map: master.color_map.clone() }
|
||||
}
|
||||
pub fn color(&self, c: &ColorRef, ph: Option<Rgba>) -> Rgba {
|
||||
let c = match &c.base {
|
||||
crate::ColorBase::Scheme { slot } if matches!(slot, SchemeSlot::Bg1 | SchemeSlot::Tx1 | SchemeSlot::Bg2 | SchemeSlot::Tx2) => {
|
||||
ColorRef { base: crate::ColorBase::Scheme { slot: self.master.map_slot(*slot) }, mods: c.mods.clone() }
|
||||
ColorRef { base: crate::ColorBase::Scheme { slot: crate::map_slot(&self.color_map, *slot) }, mods: c.mods.clone() }
|
||||
}
|
||||
_ => c.clone(),
|
||||
};
|
||||
@@ -130,11 +134,8 @@ pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm {
|
||||
/// Effective fill and the style colour (`phClr`) to resolve it with.
|
||||
pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
|
||||
let (lp, mp) = parents(ctx, shape);
|
||||
for s in [Some(shape), lp, mp].into_iter().flatten() {
|
||||
if let Some(f) = &s.fill {
|
||||
return (Some(f.clone()), None);
|
||||
}
|
||||
if let Some(st) = &s.style {
|
||||
let style_fill = |s: &Shape| {
|
||||
s.style.as_ref().map(|st| {
|
||||
let (idx, col) = &st.fill_ref;
|
||||
let ph = ctx.color(col, None);
|
||||
let f = match *idx {
|
||||
@@ -142,7 +143,25 @@ pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option<Fill>, Option<Rgba>) {
|
||||
i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(),
|
||||
i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(),
|
||||
};
|
||||
return (f, Some(ph));
|
||||
(f, Some(ph))
|
||||
})
|
||||
};
|
||||
for s in [Some(shape), lp, mp].into_iter().flatten() {
|
||||
if let Some(f) = &s.fill {
|
||||
// A gradient without stops (`a:gradFill` with no `a:gsLst`) takes them from the
|
||||
// gradient its style refers to, keeping its own direction, as PowerPoint draws it.
|
||||
if let Fill::Gradient(g) = f
|
||||
&& g.stops.is_empty()
|
||||
&& let Some((Some(Fill::Gradient(sg)), ph)) = style_fill(s)
|
||||
{
|
||||
let mut g = g.clone();
|
||||
g.stops = sg.stops;
|
||||
return (Some(Fill::Gradient(g)), ph);
|
||||
}
|
||||
return (Some(f.clone()), None);
|
||||
}
|
||||
if let Some(r) = style_fill(s) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
if shape.text_box {
|
||||
@@ -219,6 +238,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
|
||||
columns: Some(1),
|
||||
col_spacing: Some(0.0),
|
||||
warp: None,
|
||||
warp_adj: Vec::new(),
|
||||
rtl_col: Some(false),
|
||||
upright: Some(false),
|
||||
});
|
||||
@@ -266,6 +286,17 @@ pub fn run(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph, run: &RunPro
|
||||
let mut r = run.clone();
|
||||
let lvl = level_style(ctx, shape, para.level);
|
||||
r.inherit(&lvl.run);
|
||||
// Hyperlinked text is drawn in the theme's hyperlink colour and underlined, as in PowerPoint,
|
||||
// unless the run itself sets a colour (honoured since PowerPoint 2019) or an underline
|
||||
// (`u="none"` removes it). Colours and underlines from list styles don't count.
|
||||
if r.link.is_some() {
|
||||
if run.fill.is_none() {
|
||||
r.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
|
||||
}
|
||||
if run.underline.is_none() {
|
||||
r.underline = Some("sng".into());
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
+109
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::style::{Fill, Line};
|
||||
use crate::style::{ColorRef, Fill, Line};
|
||||
use crate::text::{Anchor, TextBody};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -55,6 +55,9 @@ pub struct Table {
|
||||
pub last_col: bool,
|
||||
pub band_row: bool,
|
||||
pub band_col: bool,
|
||||
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<Fill>,
|
||||
}
|
||||
|
||||
impl Default for Table {
|
||||
@@ -69,6 +72,7 @@ impl Default for Table {
|
||||
last_col: false,
|
||||
band_row: true,
|
||||
band_col: false,
|
||||
background: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +203,110 @@ impl Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in
|
||||
/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and
|
||||
/// last rows and columns, corner cells) is filled, bordered and lettered.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableStyleDef {
|
||||
/// The style's GUID (`a:tableStyleId` on tables).
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub parts: Vec<(TablePart, TablePartStyle)>,
|
||||
/// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used).
|
||||
pub background: Option<TablePartStyle>,
|
||||
/// The definition as read, written back unchanged.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
impl TableStyleDef {
|
||||
pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> {
|
||||
self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of a table style, in the order they apply (later ones win).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TablePart {
|
||||
WholeTbl,
|
||||
Band1H,
|
||||
Band2H,
|
||||
Band1V,
|
||||
Band2V,
|
||||
LastCol,
|
||||
FirstCol,
|
||||
LastRow,
|
||||
FirstRow,
|
||||
SeCell,
|
||||
SwCell,
|
||||
NeCell,
|
||||
NwCell,
|
||||
}
|
||||
|
||||
impl TablePart {
|
||||
pub const ALL: [TablePart; 13] = [
|
||||
TablePart::WholeTbl,
|
||||
TablePart::Band1H,
|
||||
TablePart::Band2H,
|
||||
TablePart::Band1V,
|
||||
TablePart::Band2V,
|
||||
TablePart::LastCol,
|
||||
TablePart::FirstCol,
|
||||
TablePart::LastRow,
|
||||
TablePart::FirstRow,
|
||||
TablePart::SeCell,
|
||||
TablePart::SwCell,
|
||||
TablePart::NeCell,
|
||||
TablePart::NwCell,
|
||||
];
|
||||
|
||||
pub fn xml(self) -> &'static str {
|
||||
match self {
|
||||
TablePart::WholeTbl => "wholeTbl",
|
||||
TablePart::Band1H => "band1H",
|
||||
TablePart::Band2H => "band2H",
|
||||
TablePart::Band1V => "band1V",
|
||||
TablePart::Band2V => "band2V",
|
||||
TablePart::LastCol => "lastCol",
|
||||
TablePart::FirstCol => "firstCol",
|
||||
TablePart::LastRow => "lastRow",
|
||||
TablePart::FirstRow => "firstRow",
|
||||
TablePart::SeCell => "seCell",
|
||||
TablePart::SwCell => "swCell",
|
||||
TablePart::NeCell => "neCell",
|
||||
TablePart::NwCell => "nwCell",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_xml(s: &str) -> Option<TablePart> {
|
||||
TablePart::ALL.into_iter().find(|p| p.xml() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's
|
||||
/// style index with its colour.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TablePartStyle {
|
||||
pub fill: Option<Fill>,
|
||||
pub fill_ref: Option<(u32, ColorRef)>,
|
||||
/// Left, right, top, bottom, inside horizontal, inside vertical.
|
||||
pub borders: [Option<TableBorder>; 6],
|
||||
pub text_color: Option<ColorRef>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
}
|
||||
|
||||
/// A border of a table style part: a line, or a theme line style reference.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum TableBorder {
|
||||
Line { line: Line },
|
||||
Ref { idx: u32, color: ColorRef },
|
||||
}
|
||||
|
||||
/// Built-in table styles: (id, label). Styles are generated from the theme accents.
|
||||
pub fn table_styles() -> Vec<(String, String)> {
|
||||
let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())];
|
||||
|
||||
@@ -62,6 +62,29 @@ fn shape_style_fill_resolves_through_theme() {
|
||||
assert!(ph.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_without_stops_takes_them_from_its_style() {
|
||||
// `a:gradFill` with only a direction: PowerPoint fills it with the stops of the theme gradient
|
||||
// the style's fillRef names, in the style's colour, along the shape's own direction.
|
||||
let p = Presentation::default();
|
||||
let s = &p.slides[0];
|
||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||
let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
|
||||
st.fill_ref.0 = 3;
|
||||
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
let (f, ph) = resolve::fill(&ctx, &sh);
|
||||
let Some(Fill::Gradient(g)) = f else { panic!("expected a gradient") };
|
||||
let Some(Fill::Gradient(theme)) = p.masters[0].theme.format.fills.get(2).cloned() else { panic!("theme fill 3 is a gradient") };
|
||||
assert_eq!(g.stops, theme.stops);
|
||||
assert_eq!(g.shape, own.shape, "the shape's own direction");
|
||||
assert_eq!(ph, Some(p.masters[0].theme.colors.get(SchemeSlot::Accent2)));
|
||||
// A solid style fill has no stops to give: the gradient stays empty (draws nothing).
|
||||
st.fill_ref.0 = 1;
|
||||
let sh = Shape { style: Some(st), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
assert_eq!(resolve::fill(&ctx, &sh).0, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let p = Presentation::default();
|
||||
@@ -132,3 +155,78 @@ fn four_by_three_master_scales() {
|
||||
let x = title.xfrm.unwrap();
|
||||
assert!(x.x + x.w <= 720.0 + 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlinked_runs_take_the_theme_hyperlink_look() {
|
||||
use crate::text::{Action, Hyperlink, Paragraph, RunProps};
|
||||
let p = Presentation::default();
|
||||
let s = &p.slides[0];
|
||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||
let title = &s.shapes[0];
|
||||
let para = Paragraph::default();
|
||||
let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false });
|
||||
let hlink = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
|
||||
// The title's master style colours its text tx1; a link is hlink and underlined instead.
|
||||
assert_eq!(resolve::run(&ctx, title, ¶, &RunProps::default()).fill, Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))));
|
||||
let r = resolve::run(&ctx, title, ¶, &RunProps { link: link.clone(), ..Default::default() });
|
||||
assert_eq!((r.fill, r.underline.as_deref()), (hlink.clone(), Some("sng")));
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Hlink), None), p.masters[0].theme.colors.get(SchemeSlot::Hlink));
|
||||
// A slide link looks the same.
|
||||
let slide = Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false });
|
||||
assert_eq!(resolve::run(&ctx, title, ¶, &RunProps { link: slide, ..Default::default() }).fill, hlink);
|
||||
// The run's own colour and underline win; u="none" removes the underline.
|
||||
let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||||
let own = RunProps { link: link.clone(), fill: red.clone(), underline: Some("none".into()), ..Default::default() };
|
||||
let r = resolve::run(&ctx, title, ¶, &own);
|
||||
assert_eq!((r.fill, r.underline.as_deref()), (red, Some("none")));
|
||||
let dbl = resolve::run(&ctx, title, ¶, &RunProps { link, underline: Some("dbl".into()), ..Default::default() });
|
||||
assert_eq!((dbl.fill, dbl.underline.as_deref()), (hlink, Some("dbl")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_colour_map_override_applies_to_its_slides() {
|
||||
// A dark "section" layout maps bg1→dk1 and tx1→lt1; a slide on it that keeps the master
|
||||
// mapping (`masterClrMapping`) follows the layout, as PowerPoint draws it.
|
||||
let mut p = Presentation::default();
|
||||
let dark: Vec<(String, String)> =
|
||||
[("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
|
||||
let layout = p.slides[0].layout;
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
let colors = m.theme.colors.clone();
|
||||
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = Some(dark.clone());
|
||||
let s: Slide = (*p.slides[0]).clone();
|
||||
let ctx = Ctx::for_slide(&p, &s).unwrap();
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Tx2), None), colors.get(SchemeSlot::Lt2));
|
||||
assert_eq!(ctx.scheme.get(SchemeSlot::Lt1), colors.get(SchemeSlot::Dk1));
|
||||
// The slide's own override wins over the layout's.
|
||||
let mut own = s.clone();
|
||||
own.color_map = Some([("bg1", "lt1"), ("tx1", "dk1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect());
|
||||
let ctx = Ctx::for_slide(&p, &own).unwrap();
|
||||
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
||||
// The layout itself draws with its override; the master keeps its own map.
|
||||
let m = &p.masters[0];
|
||||
let l = m.layout(layout).unwrap();
|
||||
assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
||||
assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_lists_and_parents_reach_into_groups() {
|
||||
let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() };
|
||||
let group = |id: u32, children: Vec<Shape>| Shape {
|
||||
id: ShapeId(id),
|
||||
kind: ShapeKind::Group { children, child: Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)];
|
||||
assert_eq!(parent_of(&tree, ShapeId(1)), Some(None));
|
||||
assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2))));
|
||||
assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4))));
|
||||
assert_eq!(parent_of(&tree, ShapeId(9)), None);
|
||||
let ids = |v: &Vec<Shape>| v.iter().map(|s| s.id.0).collect::<Vec<_>>();
|
||||
assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6]));
|
||||
assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4]));
|
||||
assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5]));
|
||||
assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none());
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@ pub struct BodyProps {
|
||||
pub col_spacing: Option<f64>,
|
||||
/// WordArt transform (`prstTxWarp`), e.g. `textArchUp`.
|
||||
pub warp: Option<String>,
|
||||
/// The warp's adjust values (`prstTxWarp/avLst`: `adj` or `adj1`, `adj2`…, raw guide values);
|
||||
/// empty = the preset's defaults.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub warp_adj: Vec<f64>,
|
||||
pub rtl_col: Option<bool>,
|
||||
/// Keep text upright when the shape rotates.
|
||||
pub upright: Option<bool>,
|
||||
@@ -119,6 +123,7 @@ impl BodyProps {
|
||||
self.col_spacing = self.col_spacing.or(p.col_spacing);
|
||||
if self.warp.is_none() {
|
||||
self.warp.clone_from(&p.warp);
|
||||
self.warp_adj.clone_from(&p.warp_adj);
|
||||
}
|
||||
self.rtl_col = self.rtl_col.or(p.rtl_col);
|
||||
self.upright = self.upright.or(p.upright);
|
||||
@@ -132,6 +137,8 @@ pub enum Align {
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
/// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
|
||||
JustLow,
|
||||
Distributed,
|
||||
}
|
||||
|
||||
@@ -142,6 +149,7 @@ impl Align {
|
||||
Align::Center => "ctr",
|
||||
Align::Right => "r",
|
||||
Align::Justify => "just",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "dist",
|
||||
}
|
||||
}
|
||||
@@ -150,7 +158,8 @@ impl Align {
|
||||
"l" => Align::Left,
|
||||
"ctr" => Align::Center,
|
||||
"r" => Align::Right,
|
||||
"just" | "justLow" | "thaiDist" => Align::Justify,
|
||||
"just" | "thaiDist" => Align::Justify,
|
||||
"justLow" => Align::JustLow,
|
||||
"dist" => Align::Distributed,
|
||||
_ => return None,
|
||||
})
|
||||
@@ -219,6 +228,10 @@ pub struct ParaProps {
|
||||
pub rtl: Option<bool>,
|
||||
pub font_align: Option<String>,
|
||||
pub east_asian_line_break: Option<bool>,
|
||||
/// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
|
||||
pub latin_line_break: Option<bool>,
|
||||
/// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
|
||||
pub hanging_punct: Option<bool>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
@@ -249,6 +262,8 @@ impl ParaProps {
|
||||
self.font_align.clone_from(&p.font_align);
|
||||
}
|
||||
self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
|
||||
self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
|
||||
self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,24 @@ pub struct FontSet {
|
||||
pub latin: String,
|
||||
pub ea: String,
|
||||
pub cs: String,
|
||||
/// Per-script typefaces (`a:font script="Arab" typeface="…"`), in document order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ScriptFont>,
|
||||
}
|
||||
|
||||
/// A theme font for one script (ISO 15924 tag, e.g. `Arab`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ScriptFont {
|
||||
pub script: String,
|
||||
pub typeface: String,
|
||||
}
|
||||
|
||||
impl FontSet {
|
||||
/// The typeface the theme gives `script` (`"Arab"`), if any.
|
||||
pub fn script(&self, script: &str) -> Option<&str> {
|
||||
self.scripts.iter().find(|f| f.script.eq_ignore_ascii_case(script) && !f.typeface.is_empty()).map(|f| f.typeface.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Word boundaries in a paragraph, as PowerPoint's Ctrl+arrow, Ctrl+Backspace/Delete and
|
||||
//! double-click see them (observed black-box on PowerPoint for Windows, see
|
||||
//! `docs/ui-parity.md`, Text editing keys).
|
||||
//!
|
||||
//! A paragraph splits into *tokens*. Each token is a run of one class of grapheme clusters plus
|
||||
//! the spaces that follow it:
|
||||
//!
|
||||
//! - **word**: letters, digits and combining marks. An apostrophe (`'` or `’`) joins a word when a
|
||||
//! letter or digit follows it (`it's`, `'tis`, but `dogs'` ends before it); a hyphen joins when
|
||||
//! a letter or digit comes before it (`a-b`, `1-2`, `a-`); `.`, `,` and `:` join between digits
|
||||
//! (`3.14`, `1,000`, `3:45`, but `e.g.` is five tokens).
|
||||
//! - **punctuation**: everything else that is not a symbol; a run of it is one token (`...`,
|
||||
//! `?!`, `),`, `。。`). `_`, `@`, `/`, `#`, quotes and brackets are punctuation.
|
||||
//! - **symbol**: emoji and pictographs (a run of them is one token, `👍👍`), `©`, `®`, `™`.
|
||||
//! - **ideograph**: each CJK ideograph, kana or Hangul syllable is a token of its own.
|
||||
//! - **tab** and **line break**: each is a token of its own, and spaces after one form their own
|
||||
//! token instead of joining it.
|
||||
//! - **space**: U+0020, no-break space, ideographic space and the other fixed-width spaces. Spaces
|
||||
//! join the token before them; spaces at the start of a paragraph (or after a tab or line break)
|
||||
//! are a token of their own.
|
||||
//!
|
||||
//! Positions are character (Unicode scalar) offsets in the paragraph text, where a line break is
|
||||
//! one `\u{b}` character. A token never splits a grapheme cluster, so the caret never lands
|
||||
//! inside an emoji sequence or between a letter and its combining marks.
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Class {
|
||||
Space,
|
||||
Tab,
|
||||
Break,
|
||||
Word,
|
||||
Ideo,
|
||||
Punct,
|
||||
Symbol,
|
||||
}
|
||||
|
||||
/// One grapheme cluster: its first character offset and class.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Cluster {
|
||||
start: usize,
|
||||
class: Class,
|
||||
}
|
||||
|
||||
/// A space for word movement: absorbed into the token before it.
|
||||
pub fn is_space(c: char) -> bool {
|
||||
matches!(c, ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}')
|
||||
}
|
||||
|
||||
/// CJK ideographs, kana and Hangul: every one is a word of its own.
|
||||
fn is_ideo(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{1100}'..='\u{11ff}'
|
||||
| '\u{2e80}'..='\u{2fdf}'
|
||||
| '\u{3005}'..='\u{3007}'
|
||||
| '\u{3021}'..='\u{3029}'
|
||||
| '\u{3031}'..='\u{3035}'
|
||||
| '\u{3038}'..='\u{303c}'
|
||||
| '\u{3040}'..='\u{309f}'
|
||||
| '\u{30a0}'..='\u{30ff}'
|
||||
| '\u{3100}'..='\u{318f}'
|
||||
| '\u{31a0}'..='\u{31ff}'
|
||||
| '\u{3400}'..='\u{4dbf}'
|
||||
| '\u{4e00}'..='\u{9fff}'
|
||||
| '\u{a960}'..='\u{a97f}'
|
||||
| '\u{ac00}'..='\u{d7ff}'
|
||||
| '\u{f900}'..='\u{faff}'
|
||||
| '\u{ff66}'..='\u{ffdc}'
|
||||
| '\u{20000}'..='\u{3134f}')
|
||||
}
|
||||
|
||||
/// Emoji, pictographs and the common letter-like symbols.
|
||||
fn is_symbol(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{a9}' | '\u{ae}' | '\u{2122}'
|
||||
| '\u{2190}'..='\u{21ff}'
|
||||
| '\u{2300}'..='\u{23ff}'
|
||||
| '\u{25a0}'..='\u{27bf}'
|
||||
| '\u{2b00}'..='\u{2bff}'
|
||||
| '\u{1f000}'..='\u{1faff}')
|
||||
}
|
||||
|
||||
fn base_class(c: char) -> Class {
|
||||
match c {
|
||||
'\u{b}' | '\n' | '\r' | '\u{2028}' | '\u{2029}' => Class::Break,
|
||||
'\t' => Class::Tab,
|
||||
c if is_space(c) => Class::Space,
|
||||
c if is_ideo(c) => Class::Ideo,
|
||||
c if c.is_alphanumeric() => Class::Word,
|
||||
c if is_symbol(c) => Class::Symbol,
|
||||
// A combining mark with no base (or any other mark) stays with letters.
|
||||
'\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word,
|
||||
_ => Class::Punct,
|
||||
}
|
||||
}
|
||||
|
||||
fn clusters(text: &str) -> Vec<Cluster> {
|
||||
let mut out: Vec<Cluster> = Vec::new();
|
||||
let mut firsts: Vec<char> = Vec::new();
|
||||
let mut pos = 0;
|
||||
for g in text.graphemes(true) {
|
||||
let n = g.chars().count();
|
||||
let first = g.chars().next().unwrap_or(' ');
|
||||
// "\r\n" is one grapheme; any other cluster takes the class of its first character.
|
||||
out.push(Cluster { start: pos, class: base_class(first) });
|
||||
firsts.push(first);
|
||||
pos += n;
|
||||
}
|
||||
// Context: apostrophes, hyphens and number separators that belong to a word.
|
||||
let classes: Vec<Class> = out.iter().map(|c| c.class).collect();
|
||||
for (i, cl) in out.iter_mut().enumerate() {
|
||||
let Some(&c) = firsts.get(i) else { continue };
|
||||
let prev = i.checked_sub(1).and_then(|j| firsts.get(j)).copied();
|
||||
let next = firsts.get(i + 1).copied();
|
||||
let joins = match c {
|
||||
'\'' | '\u{2019}' => next.is_some_and(char::is_alphanumeric),
|
||||
'-' => prev.is_some_and(char::is_alphanumeric),
|
||||
'.' | ',' | ':' => prev.is_some_and(char::is_numeric) && next.is_some_and(char::is_numeric),
|
||||
_ => false,
|
||||
};
|
||||
if joins && classes.get(i) == Some(&Class::Punct) {
|
||||
cl.class = Class::Word;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Does a token start at cluster `cur` (after `prev`)?
|
||||
fn starts_token(prev: Class, cur: Class) -> bool {
|
||||
if cur == Class::Space {
|
||||
// Spaces join the token before them, except after a tab or a line break.
|
||||
return matches!(prev, Class::Tab | Class::Break);
|
||||
}
|
||||
prev == Class::Space || prev != cur || matches!(cur, Class::Ideo | Class::Tab | Class::Break)
|
||||
}
|
||||
|
||||
/// The word stops of a paragraph's text in ascending order: 0, the start of every token, and the
|
||||
/// end of the text. Ctrl+Right goes to the next one.
|
||||
pub fn stops(text: &str) -> Vec<usize> {
|
||||
let cs = clusters(text);
|
||||
let mut out = vec![0];
|
||||
for w in cs.windows(2) {
|
||||
if let [a, b] = w
|
||||
&& starts_token(a.class, b.class)
|
||||
{
|
||||
out.push(b.start);
|
||||
}
|
||||
}
|
||||
let n = text.chars().count();
|
||||
if out.last() != Some(&n) {
|
||||
out.push(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The stops Ctrl+Left uses. PowerPoint's backward scan differs from the forward one in two
|
||||
/// observed details: a no-break or other non-ASCII space is a stop of its own (unless an ASCII
|
||||
/// space follows it), and a symbol followed by punctuation (`👍?`) is one token.
|
||||
pub fn back_stops(text: &str) -> Vec<usize> {
|
||||
let cs = clusters(text);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut out = vec![0];
|
||||
for w in cs.windows(2) {
|
||||
if let [a, b] = w {
|
||||
let symbol_punct = matches!((a.class, b.class), (Class::Symbol, Class::Punct) | (Class::Punct, Class::Symbol));
|
||||
if starts_token(a.class, b.class) && !symbol_punct {
|
||||
out.push(b.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if *c != ' ' && is_space(*c) && chars.get(i + 1) != Some(&' ') && i > 0 {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
let n = chars.len();
|
||||
if out.last() != Some(&n) {
|
||||
out.push(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The next stop after `ch` (Ctrl+Right), or `None` at the end of the text.
|
||||
pub fn next_stop(text: &str, ch: usize) -> Option<usize> {
|
||||
stops(text).into_iter().find(|s| *s > ch)
|
||||
}
|
||||
|
||||
/// The previous stop before `ch` (Ctrl+Left), or `None` at the start.
|
||||
pub fn prev_stop(text: &str, ch: usize) -> Option<usize> {
|
||||
back_stops(text).into_iter().rev().find(|s| *s < ch)
|
||||
}
|
||||
|
||||
/// Is the character at `i` a space (for word purposes)?
|
||||
fn space_at(chars: &[char], i: usize) -> bool {
|
||||
chars.get(i).is_some_and(|c| is_space(*c))
|
||||
}
|
||||
|
||||
/// The end of the token's text (before its trailing spaces) for the token containing `ch`.
|
||||
fn content_end(text: &str, ch: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let next = next_stop(text, ch).unwrap_or(chars.len());
|
||||
let mut e = ch;
|
||||
while e < next && !space_at(&chars, e) {
|
||||
e += 1;
|
||||
}
|
||||
// A token made only of spaces (leading spaces) ends at the next stop.
|
||||
if e == ch && space_at(&chars, ch) { next } else { e }
|
||||
}
|
||||
|
||||
/// The word a double-click at `ch` selects: the token under the caret without its trailing
|
||||
/// spaces; on a space just after a token, that token; in a run of spaces, the spaces.
|
||||
pub fn word_at(text: &str, ch: usize) -> (usize, usize) {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let n = chars.len();
|
||||
if n == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let ch = ch.min(n);
|
||||
let on_space = ch >= n || space_at(&chars, ch);
|
||||
let after_token = ch > 0 && !space_at(&chars, ch - 1);
|
||||
let at = if on_space && after_token { ch - 1 } else { ch.min(n - 1) };
|
||||
if space_at(&chars, at) {
|
||||
let mut s = at;
|
||||
while s > 0 && space_at(&chars, s - 1) {
|
||||
s -= 1;
|
||||
}
|
||||
let mut e = at;
|
||||
while e < n && space_at(&chars, e) {
|
||||
e += 1;
|
||||
}
|
||||
return (s, e);
|
||||
}
|
||||
let s = stops(text).into_iter().rev().find(|s| *s <= at).unwrap_or(0);
|
||||
(s, content_end(text, s).max(at + 1).min(n))
|
||||
}
|
||||
|
||||
/// What Ctrl+Delete removes from `ch` (before smart spacing): inside a token, the rest of its
|
||||
/// text; on spaces right after a token, those spaces and the next token's text; anywhere else up
|
||||
/// to the next stop. `None` at the end of the text.
|
||||
pub fn delete_forward_end(text: &str, ch: usize) -> Option<usize> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let n = chars.len();
|
||||
if ch >= n {
|
||||
return None;
|
||||
}
|
||||
let st = stops(text);
|
||||
let at_stop = st.contains(&ch);
|
||||
if !at_stop && !space_at(&chars, ch) {
|
||||
return Some(content_end(text, ch));
|
||||
}
|
||||
let prev_is_text = ch > 0 && chars.get(ch - 1).is_some_and(|c| !is_space(*c) && !matches!(base_class(*c), Class::Tab | Class::Break));
|
||||
if space_at(&chars, ch) && prev_is_text {
|
||||
let mut j = ch;
|
||||
while j < n && space_at(&chars, j) {
|
||||
j += 1;
|
||||
}
|
||||
if j >= n {
|
||||
return Some(n);
|
||||
}
|
||||
return Some(content_end(text, j));
|
||||
}
|
||||
st.into_iter().find(|s| *s > ch)
|
||||
}
|
||||
|
||||
/// Closing punctuation that a smart delete pulls its word's spaces up to (`ab cd, ef` → Ctrl+Delete
|
||||
/// on `cd` gives `ab, ef`).
|
||||
pub fn is_closing_punct(c: char) -> bool {
|
||||
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '%' | '\u{2026}' | '\u{201d}' | '\u{2019}' | '\u{bb}')
|
||||
}
|
||||
|
||||
/// Punctuation that a smart delete keeps a space after when a word follows (`cd...ef gh` →
|
||||
/// Ctrl+Delete on `ef` gives `cd... gh`).
|
||||
pub fn is_sentence_punct(c: char) -> bool {
|
||||
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '\u{2026}')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Stops drawn as `|` in the text, for readable assertions.
|
||||
fn marked(text: &str, st: &[usize]) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, c) in text.chars().enumerate() {
|
||||
if st.contains(&i) {
|
||||
out.push('|');
|
||||
}
|
||||
out.push(match c {
|
||||
'\u{b}' => '↵',
|
||||
'\t' => '→',
|
||||
c => c,
|
||||
});
|
||||
}
|
||||
if st.contains(&text.chars().count()) {
|
||||
out.push('|');
|
||||
}
|
||||
out
|
||||
}
|
||||
fn fwd(text: &str) -> String {
|
||||
marked(text, &stops(text))
|
||||
}
|
||||
fn back(text: &str) -> String {
|
||||
marked(text, &back_stops(text))
|
||||
}
|
||||
|
||||
// Every expected string below is a stop trace observed in PowerPoint for Windows.
|
||||
#[test]
|
||||
fn punctuation_and_spaces() {
|
||||
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
|
||||
let want = "|Hello|, |world|! |Foo|...|bar |baz-qux |it's |3.14 |e|.|g|. |a|_|b |x|@|y|.|com |(|end|) |\"|q|\" |end|.|";
|
||||
assert_eq!(fwd(t), want);
|
||||
assert_eq!(back(t), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphens_apostrophes_numbers() {
|
||||
let t = "a-b -b a--b a - b a- b dogs' x 'q' 1,000.5 3:45 v1.2.3 $5 50% a/b x&y #tag a+b c++ end";
|
||||
assert_eq!(
|
||||
fwd(t),
|
||||
"|a-b |-|b |a-|-|b |a |- |b |a- |b |dogs|' |x |'q|' |1,000.5 |3:45 |v1.2.3 |$|5 |50|% |a|/|b |x|&|y |#|tag |a|+|b |c|++ |end|"
|
||||
);
|
||||
let t = "it’s x a?!b a.)b a),b 1.a a1.b 1.5.x 1-2 a1-b A-1 —x x— a's 'tis o'clock ‘q’ end";
|
||||
assert_eq!(fwd(t), "|it’s |x |a|?!|b |a|.)|b |a|),|b |1|.|a |a1|.|b |1.5|.|x |1-2 |a1-b |A-1 |—|x |x|— |a's |'tis |o'clock |‘|q|’ |end|");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_emoji_symbols() {
|
||||
let t = "café e\u{301}te 👍🏽 x 👨\u{200d}👩\u{200d}👧 y ½ ®z «q» “w” ¿no? ok—em dash–en x";
|
||||
let want = "|café |e\u{301}te |👍🏽 |x |👨\u{200d}👩\u{200d}👧 |y |½ |®|z |«|q|» |“|w|” |¿|no|? |ok|—|em |dash|–|en |x|";
|
||||
assert_eq!(fwd(t), want);
|
||||
assert_eq!(back(t), want);
|
||||
// A run of emoji is one token; emoji then punctuation is two going right, one going left.
|
||||
assert_eq!(fwd("👍👍 x 👍? x👍y"), "|👍👍 |x |👍|? |x|👍|y|");
|
||||
assert_eq!(back("👍👍 x 👍? x👍y"), "|👍👍 |x |👍? |x|👍|y|");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_each_character() {
|
||||
let t = "日本語のテキストです。カタカナ漢字 abc 中文字符,标点。한국어 문장 a。。b";
|
||||
assert_eq!(fwd(t), "|日|本|語|の|テ|キ|ス|ト|で|す|。|カ|タ|カ|ナ|漢|字 |abc |中|文|字|符|,|标|点|。|한|국|어 |문|장 |a|。。|b|");
|
||||
assert_eq!(back(t), fwd(t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_words_in_logical_order() {
|
||||
let t = "Hello مرحبا بالعالم world שלום עולם end";
|
||||
assert_eq!(fwd(t), "|Hello |مرحبا |بالعالم |world |שלום |עולם |end|");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_breaks_and_other_spaces() {
|
||||
assert_eq!(fwd(" lead x mid "), "| |lead |x |mid |");
|
||||
assert_eq!(fwd("Five\u{b}six seven\tTab x"), "|Five|↵|six |seven|→|Tab |x|");
|
||||
assert_eq!(fwd("a\t b x \u{b} y q\u{b}\u{b}z"), "|a|→| |b |x |↵| |y |q|↵|↵|z|");
|
||||
let t = "a\u{a0}\u{a0}b a\u{3000}b a\u{2003}b a \u{a0}b a\t\tb a \tb a\u{a0} b end";
|
||||
assert_eq!(fwd(t), "|a\u{a0}\u{a0}|b |a\u{3000}|b |a\u{2003}|b |a \u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|");
|
||||
assert_eq!(back(t), "|a|\u{a0}|\u{a0}|b |a|\u{3000}|b |a|\u{2003}|b |a |\u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_and_prev() {
|
||||
let t = " lead x mid ";
|
||||
assert_eq!(next_stop(t, 1), Some(2));
|
||||
assert_eq!(next_stop(t, 9), Some(11));
|
||||
assert_eq!(next_stop(t, 16), None);
|
||||
assert_eq!(prev_stop(t, 1), Some(0));
|
||||
assert_eq!(prev_stop(t, 9), Some(7));
|
||||
assert_eq!(prev_stop(t, 15), Some(11));
|
||||
assert_eq!(prev_stop(t, 0), None);
|
||||
assert_eq!(next_stop("", 0), None);
|
||||
assert_eq!(prev_stop("", 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_word() {
|
||||
let t = "Hello world, foo bar";
|
||||
assert_eq!(word_at(t, 7), (6, 11));
|
||||
assert_eq!(word_at(t, 15), (14, 17));
|
||||
assert_eq!(word_at(t, 11), (11, 12));
|
||||
assert_eq!(word_at(t, 12), (11, 12));
|
||||
assert_eq!(word_at(t, 13), (12, 14));
|
||||
assert_eq!(word_at(t, 21), (18, 21));
|
||||
assert_eq!(word_at("日本語のテキスト abc", 5), (5, 6));
|
||||
assert_eq!(word_at("", 3), (0, 0));
|
||||
assert_eq!(word_at("x👨\u{200d}👩\u{200d}👧y", 2), (1, 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_forward_extent() {
|
||||
let t = "ab cd...ef gh";
|
||||
let ends: Vec<Option<usize>> = (0..=t.len()).map(|c| delete_forward_end(t, c)).collect();
|
||||
// Before smart spacing: PowerPoint's results after it are tested in `edit`.
|
||||
assert_eq!(
|
||||
ends,
|
||||
vec![
|
||||
Some(5),
|
||||
Some(2),
|
||||
Some(7),
|
||||
Some(5),
|
||||
Some(5),
|
||||
Some(7),
|
||||
Some(7),
|
||||
Some(10),
|
||||
Some(10),
|
||||
Some(10),
|
||||
Some(13),
|
||||
Some(12),
|
||||
Some(15),
|
||||
Some(15),
|
||||
Some(15),
|
||||
None
|
||||
]
|
||||
);
|
||||
assert_eq!(delete_forward_end("ab\t cd", 2), Some(3));
|
||||
assert_eq!(delete_forward_end("ab ", 2), Some(4));
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,14 @@ pub enum PptxError {
|
||||
}
|
||||
|
||||
/// Does this look like an OOXML package (zip with `[Content_Types].xml`)?
|
||||
///
|
||||
/// The part is looked up in the zip's central directory, not in the leading bytes: writers may
|
||||
/// store it anywhere in the archive (recent PowerPoint builds put it last, megabytes in).
|
||||
pub fn sniff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"PK") && bytes.windows(19).take(4096).any(|w| w == b"[Content_Types].xml")
|
||||
if !bytes.starts_with(b"PK") {
|
||||
return false;
|
||||
}
|
||||
zip::ZipArchive::new(std::io::Cursor::new(bytes)).map(|z| z.index_for_name("[Content_Types].xml").is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Read a `.pptx` / `.potx` / `.ppsx` package.
|
||||
|
||||
@@ -77,6 +77,12 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
|
||||
"solidFill" => Fill::Solid { color: color(f).unwrap_or(ColorRef::rgb(Rgba::BLACK)) },
|
||||
"gradFill" => Fill::Gradient(gradient(f)),
|
||||
"blipFill" => match blip_fill(imp, part, f) {
|
||||
// A picture filling a shape or background with neither `a:stretch` nor `a:tile` is
|
||||
// tiled at its own size, as PowerPoint draws it.
|
||||
Some(mut p) if f.child("stretch").is_none() && f.child("tile").is_none() => {
|
||||
p.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
Fill::Picture(p)
|
||||
}
|
||||
Some(p) => Fill::Picture(p),
|
||||
None => Fill::None,
|
||||
},
|
||||
@@ -315,13 +321,45 @@ pub fn body_pr(b: &El) -> BodyProps {
|
||||
}
|
||||
"spAutoFit" => p.autofit = Some(AutoFit::Shape),
|
||||
"noAutofit" => p.autofit = Some(AutoFit::None),
|
||||
"prstTxWarp" => p.warp = e.attr("prst").filter(|w| *w != "textNoShape").map(String::from),
|
||||
"prstTxWarp" => {
|
||||
p.warp = e.attr("prst").filter(|w| *w != "textNoShape" && !w.is_empty()).map(String::from);
|
||||
p.warp_adj = p.warp.as_deref().map(|w| warp_adjusts(w, e)).unwrap_or_default();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// `prstTxWarp/avLst` guides `adj` / `adj1`… as raw values by index (empty when none is given);
|
||||
/// slots the file leaves out keep the preset's default.
|
||||
fn warp_adjusts(preset: &str, e: &El) -> Vec<f64> {
|
||||
let mut adj: Vec<f64> = Vec::new();
|
||||
let Some(av) = e.child("avLst") else { return adj };
|
||||
let defaults = deckcraft_geom::warp::adjust_defaults(preset);
|
||||
for gd in av.children_named("gd").take(16) {
|
||||
let n = gd.attr("name").unwrap_or("");
|
||||
let idx = if n == "adj" {
|
||||
0
|
||||
} else if let Some(k) = n.strip_prefix("adj").and_then(|k| k.parse::<usize>().ok()).filter(|k| (1..=8).contains(k)) {
|
||||
k - 1
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(v) = gd.attr("fmla").and_then(|f| f.trim().strip_prefix("val")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
while adj.len() <= idx {
|
||||
adj.push(defaults.get(adj.len()).copied().unwrap_or(0.0));
|
||||
}
|
||||
if let Some(slot) = adj.get_mut(idx) {
|
||||
*slot = v.clamp(-1.0e9, 1.0e9);
|
||||
}
|
||||
}
|
||||
adj
|
||||
}
|
||||
|
||||
fn spacing(e: &El) -> Option<Spacing> {
|
||||
if let Some(p) = e.child("spcPct") {
|
||||
return Some(Spacing::Pct(pct(p, "val").unwrap_or(1.0).clamp(0.0, 100.0)));
|
||||
@@ -339,6 +377,8 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
|
||||
rtl: p.bool("rtl"),
|
||||
font_align: p.attr("fontAlgn").map(String::from),
|
||||
east_asian_line_break: p.bool("eaLnBrk"),
|
||||
latin_line_break: p.bool("latinLnBrk"),
|
||||
hanging_punct: p.bool("hangingPunct"),
|
||||
default_tab: pt(p, "defTabSz"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
+171
-1
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use deckcraft_geom::{Size, emu_to_pt};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{
|
||||
Background, Comment, CustomShow, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
|
||||
Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
|
||||
Properties, Section, ShowSettings, Slide, SlideId, defaults,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct Imp<'a> {
|
||||
pub next: u32,
|
||||
pub slide_by_part: HashMap<String, SlideId>,
|
||||
pub custom_show_names: HashMap<String, String>,
|
||||
/// GUIDs (upper case) of the table styles the file defines.
|
||||
pub table_style_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Imp<'_> {
|
||||
@@ -230,6 +232,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
next: max_id.max(1),
|
||||
slide_by_part: HashMap::new(),
|
||||
custom_show_names: HashMap::new(),
|
||||
table_style_ids: Vec::new(),
|
||||
};
|
||||
// Slide ids first so hyperlinks anywhere can point at slides.
|
||||
let mut slide_ids: Vec<(u32, String, SlideId)> = vec![];
|
||||
@@ -269,6 +272,13 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
if let Some(dts) = pres_el.child("defaultTextStyle") {
|
||||
p.default_text_style = dml::list_style(&mut imp, &pres_part, dts);
|
||||
}
|
||||
if let Some(r) = pres_part.rels.first_of("tableStyles")
|
||||
&& let Some(d) = load(&pkg, &r.target)
|
||||
{
|
||||
let part = Part::new(&pkg, &r.target, &d);
|
||||
p.table_styles = table_styles(&mut imp, &part, &d.root);
|
||||
imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect();
|
||||
}
|
||||
|
||||
// Masters and layouts.
|
||||
let mut layout_by_part: HashMap<String, LayoutId> = HashMap::new();
|
||||
@@ -387,6 +397,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default();
|
||||
}
|
||||
|
||||
inherit_placeholder_geometry(&mut p);
|
||||
p.header_footer = header_footer(&p);
|
||||
p.media = std::mem::take(&mut imp.media);
|
||||
p.next_id = imp.next;
|
||||
@@ -419,6 +430,154 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> {
|
||||
cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect()
|
||||
}
|
||||
|
||||
/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the
|
||||
/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a
|
||||
/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left
|
||||
/// is a rectangle.
|
||||
fn inherit_placeholder_geometry(p: &mut Presentation) {
|
||||
let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty());
|
||||
// Masters first, then layouts (from their masters), then slides (from layouts and masters).
|
||||
for m in &mut p.masters {
|
||||
let m = Arc::make_mut(m);
|
||||
walk_shapes_mut(&mut m.shapes, &mut |s| {
|
||||
if unset(&s.geom) {
|
||||
s.geom = Geom::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut layout_geoms: Vec<(LayoutId, usize, Geom)> = vec![];
|
||||
for m in &p.masters {
|
||||
for l in &m.layouts {
|
||||
let ctx = deckcraft_model::resolve::Ctx::for_layout(p, m, l);
|
||||
for (i, s) in l.shapes.iter().enumerate() {
|
||||
if unset(&s.geom) {
|
||||
let (_, mp) = deckcraft_model::resolve::parents(&ctx, s);
|
||||
layout_geoms.push((l.id, i, mp.map(|m| m.geom.clone()).filter(|g| !unset(g)).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &mut p.masters {
|
||||
let m = Arc::make_mut(m);
|
||||
for l in &mut m.layouts {
|
||||
for (id, i, g) in layout_geoms.iter().filter(|(id, _, _)| *id == l.id) {
|
||||
let _ = id;
|
||||
if let Some(s) = l.shapes.get_mut(*i) {
|
||||
s.geom = g.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut slide_geoms: Vec<(usize, usize, Geom)> = vec![];
|
||||
for (si, sl) in p.slides.iter().enumerate() {
|
||||
let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(p, sl) else { continue };
|
||||
for (i, s) in sl.shapes.iter().enumerate() {
|
||||
if unset(&s.geom) {
|
||||
let (lp, mp) = deckcraft_model::resolve::parents(&ctx, s);
|
||||
let g = lp.map(|l| l.geom.clone()).filter(|g| !unset(g)).or_else(|| mp.map(|m| m.geom.clone()).filter(|g| !unset(g)));
|
||||
slide_geoms.push((si, i, g.unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (si, i, g) in slide_geoms {
|
||||
if let Some(sl) = p.slides.get_mut(si)
|
||||
&& let Some(s) = Arc::make_mut(sl).shapes.get_mut(i)
|
||||
{
|
||||
s.geom = g;
|
||||
}
|
||||
}
|
||||
// Placeholders inside groups (rare) and anything still unset: rectangles.
|
||||
for sl in &mut p.slides {
|
||||
walk_shapes_mut(&mut Arc::make_mut(sl).shapes, &mut |s| {
|
||||
if unset(&s.geom) {
|
||||
s.geom = Geom::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
for m in &mut p.masters {
|
||||
for l in &mut Arc::make_mut(m).layouts {
|
||||
walk_shapes_mut(&mut l.shapes, &mut |s| {
|
||||
if unset(&s.geom) {
|
||||
s.geom = Geom::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every shape, groups included.
|
||||
fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
|
||||
for s in shapes {
|
||||
f(s);
|
||||
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut s.kind {
|
||||
walk_shapes_mut(children, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `a:tblStyleLst`: the table styles the file defines.
|
||||
fn table_styles(imp: &mut Imp, part: &Part, root: &El) -> Vec<deckcraft_model::table::TableStyleDef> {
|
||||
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
|
||||
root.children_named("tblStyle")
|
||||
.take(256)
|
||||
.filter_map(|st| {
|
||||
let id = st.attr("styleId")?.to_ascii_uppercase();
|
||||
let mut def = TableStyleDef { id, name: st.attr("styleName").unwrap_or("").to_string(), raw: part.keep(st), ..Default::default() };
|
||||
if let Some(bg) = st.child("tblBg") {
|
||||
let mut ps = TablePartStyle::default();
|
||||
if let Some(f) = bg.elements().find(|e| e.local().ends_with("Fill")) {
|
||||
ps.fill = dml::fill_el(imp, part, f);
|
||||
}
|
||||
if let Some(fr) = bg.child("fillRef") {
|
||||
ps.fill_ref = Some((
|
||||
fr.u32("idx").unwrap_or(0),
|
||||
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
));
|
||||
}
|
||||
def.background = Some(ps);
|
||||
}
|
||||
for el in st.elements() {
|
||||
let Some(kind) = TablePart::from_xml(el.local()) else { continue };
|
||||
let mut ps = TablePartStyle::default();
|
||||
if let Some(tx) = el.child("tcTxStyle") {
|
||||
ps.text_color = dml::color(tx);
|
||||
ps.bold = tx.attr("b").map(|v| v == "on");
|
||||
ps.italic = tx.attr("i").map(|v| v == "on");
|
||||
}
|
||||
if let Some(tc) = el.child("tcStyle") {
|
||||
if let Some(f) = tc.child("fill").and_then(|f| f.elements().next()) {
|
||||
ps.fill = dml::fill_el(imp, part, f);
|
||||
}
|
||||
if let Some(fr) = tc.child("fillRef") {
|
||||
ps.fill_ref = Some((
|
||||
fr.u32("idx").unwrap_or(0),
|
||||
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
));
|
||||
}
|
||||
if let Some(bd) = tc.child("tcBdr") {
|
||||
for (i, side) in ["left", "right", "top", "bottom", "insideH", "insideV"].iter().enumerate() {
|
||||
let Some(b) = bd.child(side) else { continue };
|
||||
let border = if let Some(ln) = b.child("ln") {
|
||||
Some(TableBorder::Line { line: dml::line(imp, part, ln) })
|
||||
} else {
|
||||
b.child("lnRef").map(|r| TableBorder::Ref {
|
||||
idx: r.u32("idx").unwrap_or(0),
|
||||
color: dml::color(r).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
})
|
||||
};
|
||||
if let Some(slot) = ps.borders.get_mut(i) {
|
||||
*slot = border;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
def.parts.push((kind, ps));
|
||||
}
|
||||
Some(def)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
|
||||
let bg = csld.child("bg")?;
|
||||
if let Some(pr) = bg.child("bgPr") {
|
||||
@@ -523,12 +682,18 @@ fn read_layout(imp: &mut Imp, part: &Part, doc: &Doc) -> Layout {
|
||||
l.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0);
|
||||
}
|
||||
}
|
||||
l.color_map = color_map_override(root);
|
||||
if let Some(ext) = root.child("extLst").filter(|e| !has_rel_refs(e)) {
|
||||
l.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect());
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// A layout's or slide's `clrMapOvr/overrideClrMapping` (None for `masterClrMapping` or none).
|
||||
fn color_map_override(root: &El) -> Option<Vec<(String, String)>> {
|
||||
root.child("clrMapOvr")?.child("overrideClrMapping").map(color_map)
|
||||
}
|
||||
|
||||
fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: LayoutId) -> Slide {
|
||||
let root = &doc.root;
|
||||
let mut s = Slide {
|
||||
@@ -536,6 +701,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
|
||||
layout,
|
||||
hidden: root.bool("show") == Some(false),
|
||||
show_master_shapes: root.bool("showMasterSp").unwrap_or(true),
|
||||
color_map: color_map_override(root),
|
||||
..Default::default()
|
||||
};
|
||||
let mut ctx = shapes::IdCtx::default();
|
||||
@@ -545,6 +711,10 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
|
||||
if let Some(tree) = csld.child("spTree") {
|
||||
s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0);
|
||||
}
|
||||
if let Some(c) = csld.child("controls") {
|
||||
let pics = shapes::controls(imp, part, &mut ctx, c);
|
||||
s.shapes.extend(pics);
|
||||
}
|
||||
}
|
||||
if let Some(t) = root.child("transition") {
|
||||
s.transition = timing::transition(imp, part, t);
|
||||
|
||||
@@ -87,6 +87,22 @@ pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: us
|
||||
out
|
||||
}
|
||||
|
||||
/// ActiveX controls (`p:controls`): each control's picture (`p:pic`), the image PowerPoint shows
|
||||
/// for it, as a picture above the slide's shapes. Controls without one are left out.
|
||||
pub fn controls(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, controls: &El) -> Vec<Shape> {
|
||||
let mut out = vec![];
|
||||
for c in controls.children_named("control") {
|
||||
if ctx.count >= MAX_SHAPES {
|
||||
break;
|
||||
}
|
||||
if let Some(p) = c.child("pic") {
|
||||
ctx.count += 1;
|
||||
out.push(pic(imp, part, ctx, p));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) {
|
||||
if depth > MAX_TREE_DEPTH {
|
||||
return;
|
||||
@@ -104,6 +120,18 @@ fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// PowerPoint's Lock (right-click > Lock, the Selection pane padlock) writes a full set of locking
|
||||
/// attributes; it reads an object as locked only when the whole set for its kind is there, so a
|
||||
/// lone `noMove` or `noResize` (other tools' partial locks) isn't Lock.
|
||||
fn is_locked(l: &El) -> bool {
|
||||
let set: &[&str] = match l.local() {
|
||||
"grpSpLocks" => &["noGrp", "noUngrp", "noRot", "noMove", "noResize"],
|
||||
"graphicFrameLocks" => &["noGrp", "noDrilldown", "noMove", "noResize"],
|
||||
_ => &["noGrp", "noRot", "noMove", "noResize", "noEditPoints", "noAdjustHandles", "noChangeArrowheads", "noChangeShapeType"],
|
||||
};
|
||||
set.iter().all(|a| l.bool(a) == Some(true))
|
||||
}
|
||||
|
||||
/// Common non-visual properties (`p:nvSpPr`, `p:nvPicPr`…).
|
||||
fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
|
||||
let c = nv.and_then(|n| n.child("cNvPr"));
|
||||
@@ -122,10 +150,10 @@ fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
|
||||
match e.local() {
|
||||
"cNvSpPr" => {
|
||||
s.text_box = e.bool("txBox").unwrap_or(false);
|
||||
s.locked = e.child("spLocks").is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
|
||||
s.locked = e.child("spLocks").is_some_and(is_locked);
|
||||
}
|
||||
"cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => {
|
||||
s.locked = e.elements().next().is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
|
||||
s.locked = e.elements().find(|l| l.local().ends_with("Locks")).is_some_and(is_locked);
|
||||
}
|
||||
"nvPr" => {
|
||||
if let Some(ph) = e.child("ph") {
|
||||
@@ -180,6 +208,15 @@ fn preset_geom(pg: &El) -> Geom {
|
||||
Geom::Preset { name, adj }
|
||||
}
|
||||
|
||||
/// A placeholder whose `spPr` names no geometry takes its layout's (or master's) placeholder's:
|
||||
/// marked here with an empty custom geometry, filled in once the layouts are read
|
||||
/// (`inherit_placeholder_geometry`).
|
||||
fn mark_inherited_geometry(s: &mut Shape, pr: Option<&El>) {
|
||||
if s.ph.is_some() && pr.is_none_or(|pr| pr.child("prstGeom").is_none() && pr.child("custGeom").is_none()) {
|
||||
s.geom = Geom::Custom { paths: vec![] };
|
||||
}
|
||||
}
|
||||
|
||||
/// `spPr` → transform, geometry, fill, line, effects.
|
||||
fn sp_pr(imp: &mut Imp, part: &Part, s: &mut Shape, pr: &El) {
|
||||
if let Some(x) = pr.child("xfrm") {
|
||||
@@ -229,6 +266,7 @@ fn sp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape {
|
||||
if let Some(pr) = e.child("spPr") {
|
||||
sp_pr(imp, part, &mut s, pr);
|
||||
}
|
||||
mark_inherited_geometry(&mut s, e.child("spPr"));
|
||||
if e.bool("useBgFill") == Some(true) {
|
||||
s.fill = Some(Fill::Background);
|
||||
}
|
||||
@@ -346,7 +384,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
let mut pics = vec![];
|
||||
e.find_all("blip", &mut pics);
|
||||
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
|
||||
};
|
||||
}
|
||||
.or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?)));
|
||||
let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) {
|
||||
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
|
||||
} else if uri.ends_with("/diagram") {
|
||||
@@ -360,6 +399,21 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
s
|
||||
}
|
||||
|
||||
/// The preview picture of an old-style embedded object (`p:oleObj spid` with no `p:pic`): the
|
||||
/// `v:imagedata` of the legacy VML drawing shape with that id, as PowerPoint 2007 wrote them.
|
||||
fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option<deckcraft_model::MediaId> {
|
||||
for rel in part.rels.items.iter().filter(|r| r.kind == "vmlDrawing" && !r.external) {
|
||||
let Some(doc) = imp.pkg.xml(&rel.target) else { continue };
|
||||
let mut shapes = vec![];
|
||||
doc.root.find_all("shape", &mut shapes);
|
||||
let Some(img) = shapes.into_iter().find(|s| s.attr("id") == Some(spid)).and_then(|s| s.find("imagedata")) else { continue };
|
||||
let Some(id) = img.attr("o:relid").or(img.attr("r:id")) else { continue };
|
||||
let target = imp.pkg.rels(&rel.target).get(id).filter(|r| !r.external).map(|r| r.target.clone())?;
|
||||
return imp.media_for(&target);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||
let rel_ids = gd.child("relIds")?;
|
||||
@@ -384,6 +438,7 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) ->
|
||||
fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
|
||||
let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() };
|
||||
if let Some(pr) = tbl.child("tblPr") {
|
||||
t.background = pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(imp, part, f));
|
||||
t.first_row = pr.bool("firstRow").unwrap_or(false);
|
||||
t.first_col = pr.bool("firstCol").unwrap_or(false);
|
||||
t.last_row = pr.bool("lastRow").unwrap_or(false);
|
||||
@@ -391,7 +446,11 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
|
||||
t.band_row = pr.bool("bandRow").unwrap_or(false);
|
||||
t.band_col = pr.bool("bandCol").unwrap_or(false);
|
||||
if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) {
|
||||
t.style = crate::tables::style_from_guid(id.trim());
|
||||
// A style the file defines is drawn from its definition (by its GUID).
|
||||
// DeckCraft's own styles (written with GUIDs derived from their ids) map back to them.
|
||||
let up = id.trim().to_ascii_uppercase();
|
||||
t.style =
|
||||
if imp.table_style_ids.contains(&up) && !crate::tables::is_own_guid(&up) { up } else { crate::tables::style_from_guid(id.trim()) };
|
||||
}
|
||||
}
|
||||
t.cols = tbl
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
|
||||
use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
|
||||
use deckcraft_model::style::{ColorBase, Effects};
|
||||
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, Theme, default_format};
|
||||
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, ScriptFont, Theme, default_format};
|
||||
|
||||
use super::{Imp, Part, dml};
|
||||
use crate::xml::El;
|
||||
|
||||
fn font_set(e: Option<&El>) -> FontSet {
|
||||
let tf = |n: &str| e.and_then(|e| e.child(n)).and_then(|c| c.attr("typeface")).unwrap_or("").to_string();
|
||||
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs") }
|
||||
let scripts = e
|
||||
.map(|e| {
|
||||
e.children_named("font")
|
||||
.filter_map(|f| Some(ScriptFont { script: f.attr("script")?.to_string(), typeface: f.attr("typeface").unwrap_or("").to_string() }))
|
||||
.take(256)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs"), scripts }
|
||||
}
|
||||
|
||||
pub fn read_theme(imp: &mut Imp, part: &Part, root: &El) -> Theme {
|
||||
|
||||
@@ -47,6 +47,12 @@ fn own_guid(style: &str) -> String {
|
||||
guid(&format!("deckcraft-table-style:{style}"))
|
||||
}
|
||||
|
||||
/// Is `g` the GUID DeckCraft writes for one of its own styles?
|
||||
pub fn is_own_guid(g: &str) -> bool {
|
||||
let up = g.to_ascii_uppercase();
|
||||
deckcraft_model::table::table_styles().iter().any(|(id, _)| own_guid(id) == up)
|
||||
}
|
||||
|
||||
/// Table style GUID → our style id (or the GUID itself when unknown).
|
||||
pub fn style_from_guid(g: &str) -> String {
|
||||
let up = g.to_ascii_uppercase();
|
||||
@@ -198,8 +204,9 @@ fn style_def(w: &mut W, id: &str, name: &str) {
|
||||
w.close("a:tblStyle");
|
||||
}
|
||||
|
||||
/// `ppt/tableStyles.xml` with definitions for the styles in `used`.
|
||||
pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
|
||||
/// `ppt/tableStyles.xml` with definitions for the styles in `used`: ours, generated, and those the
|
||||
/// opened file defined (`defs`), written back as they were read.
|
||||
pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableStyleDef]) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
|
||||
let names = deckcraft_model::table::table_styles();
|
||||
@@ -209,6 +216,12 @@ pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
|
||||
continue;
|
||||
}
|
||||
done.push(s);
|
||||
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
|
||||
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
|
||||
{
|
||||
w.raw(&d.raw);
|
||||
continue;
|
||||
}
|
||||
let (_, define) = guid_for_style(s);
|
||||
if !define {
|
||||
continue;
|
||||
@@ -231,7 +244,7 @@ mod tests {
|
||||
"nwCell",
|
||||
];
|
||||
let used: Vec<String> = deckcraft_model::table::table_styles().into_iter().map(|(id, _)| id).collect();
|
||||
let d = crate::xml::parse(&table_styles_xml(&used)).unwrap();
|
||||
let d = crate::xml::parse(&table_styles_xml(&used, &[])).unwrap();
|
||||
let mut n = 0;
|
||||
for st in d.root.children_named("tblStyle") {
|
||||
n += 1;
|
||||
|
||||
@@ -78,22 +78,22 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
w.open0("a:gsLst");
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.is_empty() {
|
||||
stops.push(deckcraft_model::style::GradientStop { pos: 0.0, color: ColorRef::rgb(deckcraft_color::Rgba::WHITE) });
|
||||
}
|
||||
if stops.len() == 1 {
|
||||
let mut s = stops[0].clone();
|
||||
s.pos = 1.0;
|
||||
stops.push(s);
|
||||
}
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
if !stops.is_empty() {
|
||||
w.open0("a:gsLst");
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => {
|
||||
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
|
||||
@@ -392,7 +392,19 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
w.open("a:bodyPr", a);
|
||||
if let Some(p) = warp {
|
||||
w.open("a:prstTxWarp", A::new().a("prst", p));
|
||||
w.empty0("a:avLst");
|
||||
let adj: Vec<(usize, i64)> =
|
||||
b.warp_adj.iter().take(8).enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| (i, v.round().clamp(-2.0e9, 2.0e9) as i64)).collect();
|
||||
if adj.is_empty() {
|
||||
w.empty0("a:avLst");
|
||||
} else {
|
||||
let single = deckcraft_geom::warp::adjust_count(p) <= 1 && b.warp_adj.len() == 1;
|
||||
w.open0("a:avLst");
|
||||
for (i, v) in adj {
|
||||
let n = if single { "adj".to_string() } else { format!("adj{}", i + 1) };
|
||||
w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}")));
|
||||
}
|
||||
w.close("a:avLst");
|
||||
}
|
||||
w.close("a:prstTxWarp");
|
||||
}
|
||||
match b.autofit {
|
||||
@@ -432,6 +444,8 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
.o("defTabSz", p.default_tab.map(|v| emu(v).max(0)))
|
||||
.b("rtl", p.rtl)
|
||||
.b("eaLnBrk", p.east_asian_line_break)
|
||||
.b("latinLnBrk", p.latin_line_break)
|
||||
.b("hangingPunct", p.hanging_punct)
|
||||
.o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b")));
|
||||
let has_kids = p.line_spacing.is_some()
|
||||
|| p.space_before.is_some()
|
||||
|
||||
@@ -227,6 +227,21 @@ const CLR_KEYS: [(&str, &str); 12] = [
|
||||
];
|
||||
|
||||
fn clr_map(w: &mut W, map: &[(String, String)]) {
|
||||
w.empty("p:clrMap", clr_map_attrs(map));
|
||||
}
|
||||
|
||||
/// `p:clrMapOvr`: the layout's or slide's own colour map, else `a:masterClrMapping`.
|
||||
fn clr_map_ovr(w: &mut W, map: Option<&[(String, String)]>) {
|
||||
w.open0("p:clrMapOvr");
|
||||
match map {
|
||||
Some(m) => w.empty("a:overrideClrMapping", clr_map_attrs(m)),
|
||||
None => w.empty0("a:masterClrMapping"),
|
||||
}
|
||||
w.close("p:clrMapOvr");
|
||||
}
|
||||
|
||||
/// The twelve colour map attributes, each a theme slot (the default for anything else).
|
||||
fn clr_map_attrs(map: &[(String, String)]) -> A {
|
||||
let mut a = A::new();
|
||||
for (k, d) in CLR_KEYS {
|
||||
let v = map
|
||||
@@ -237,7 +252,7 @@ fn clr_map(w: &mut W, map: &[(String, String)]) {
|
||||
.unwrap_or(d);
|
||||
a = a.a(k, v);
|
||||
}
|
||||
w.empty("p:clrMap", a);
|
||||
a
|
||||
}
|
||||
|
||||
fn ext_list(w: &mut W, raw: Option<&str>) {
|
||||
@@ -497,7 +512,7 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
|
||||
pres.rels.add("tableStyles", "tableStyles.xml", false);
|
||||
x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p));
|
||||
x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml());
|
||||
let ts = crate::tables::table_styles_xml(&x.table_styles);
|
||||
let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles);
|
||||
x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts);
|
||||
let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids);
|
||||
x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml);
|
||||
@@ -522,9 +537,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec<u8> {
|
||||
A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve),
|
||||
);
|
||||
csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes);
|
||||
w.open0("p:clrMapOvr");
|
||||
w.empty0("a:masterClrMapping");
|
||||
w.close("p:clrMapOvr");
|
||||
clr_map_ovr(&mut w, l.color_map.as_deref());
|
||||
ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref());
|
||||
w.close("p:sldLayout");
|
||||
w.finish()
|
||||
@@ -556,9 +569,7 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0")));
|
||||
let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes);
|
||||
w.open0("p:clrMapOvr");
|
||||
w.empty0("a:masterClrMapping");
|
||||
w.close("p:clrMapOvr");
|
||||
clr_map_ovr(&mut w, s.color_map.as_deref());
|
||||
if let Some(t) = &s.transition {
|
||||
timing::transition(&mut w, x, o, t);
|
||||
}
|
||||
|
||||
@@ -177,8 +177,24 @@ fn nv_pr(w: &mut W, s: &Shape) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and
|
||||
/// reads back as locked): see `read::shapes::is_locked`.
|
||||
fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) {
|
||||
let a = extra.t("noGrp", s.ph.is_some()).t("noMove", s.locked).t("noResize", s.locked);
|
||||
let a = extra.t("noGrp", s.ph.is_some() || s.locked);
|
||||
let a = match tag {
|
||||
"a:grpSpLocks" => a.t("noUngrp", s.locked).t("noRot", s.locked).t("noMove", s.locked).t("noResize", s.locked),
|
||||
_ => {
|
||||
let a = a
|
||||
.t("noRot", s.locked)
|
||||
.t("noMove", s.locked)
|
||||
.t("noResize", s.locked)
|
||||
.t("noEditPoints", s.locked)
|
||||
.t("noAdjustHandles", s.locked)
|
||||
.t("noChangeArrowheads", s.locked)
|
||||
.t("noChangeShapeType", s.locked);
|
||||
if tag == "a:picLocks" { a.t("noCrop", s.locked) } else { a }
|
||||
}
|
||||
};
|
||||
if a.v.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -458,7 +474,7 @@ fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) {
|
||||
w.open0("p:nvGraphicFramePr");
|
||||
cnvpr(w, x, o, id, s);
|
||||
w.open0("p:cNvGraphicFramePr");
|
||||
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noMove", s.locked).t("noResize", s.locked));
|
||||
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked));
|
||||
w.close("p:cNvGraphicFramePr");
|
||||
nv_pr(w, s);
|
||||
w.close("p:nvGraphicFramePr");
|
||||
@@ -494,6 +510,9 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
||||
.t("bandRow", t.band_row)
|
||||
.t("bandCol", t.band_col),
|
||||
);
|
||||
if let Some(f) = &t.background {
|
||||
dml::fill(w, x, o, f);
|
||||
}
|
||||
w.elt("a:tableStyleId", &guid);
|
||||
w.close("a:tblPr");
|
||||
w.open0("a:tblGrid");
|
||||
|
||||
@@ -13,6 +13,9 @@ fn font_set(w: &mut W, tag: &str, f: &FontSet) {
|
||||
w.empty("a:latin", A::new().a("typeface", if f.latin.is_empty() { "Arial" } else { &f.latin }));
|
||||
w.empty("a:ea", A::new().a("typeface", &f.ea));
|
||||
w.empty("a:cs", A::new().a("typeface", &f.cs));
|
||||
for sf in &f.scripts {
|
||||
w.empty("a:font", A::new().a("script", &sf.script).a("typeface", &sf.typeface));
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Fills read from decks written by other tools.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
/// `bytes` with `edit` applied to every slide part.
|
||||
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
let name = f.name().to_string();
|
||||
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// A deck with one shape filled with a stretched picture, exported.
|
||||
fn deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let media = p.add_media("tile.png", "image/png", png());
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Textured".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::Picture(PictureFill { media, ..Default::default() })),
|
||||
..Default::default()
|
||||
});
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
fn textured_mode(bytes: &[u8]) -> PictureMode {
|
||||
let q = deckcraft_pptx::import(bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape");
|
||||
match &s.fill {
|
||||
Some(Fill::Picture(pf)) => pf.mode.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_picture_fill_without_stretch_or_tile_tiles() {
|
||||
// tdf#128596: `<a:blipFill><a:blip/></a:blipFill>` on a shape, as some tools write it;
|
||||
// PowerPoint tiles the picture at its own size.
|
||||
let bare = edit_slides(&deck(), |s| {
|
||||
let s = regex_free_strip(&s, "<a:stretch>", "</a:stretch>");
|
||||
assert!(!s.contains("<a:stretch"), "the test deck lost its stretch");
|
||||
s
|
||||
});
|
||||
assert!(matches!(textured_mode(&bare), PictureMode::Tile { sx, sy, .. } if sx == 1.0 && sy == 1.0));
|
||||
// An explicit stretch is still a stretch.
|
||||
assert!(matches!(textured_mode(&deck()), PictureMode::Stretch { .. }));
|
||||
}
|
||||
|
||||
/// `s` without the first `open`…`close` span inside a `blipFill`.
|
||||
fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
|
||||
let Some(b) = s.find("<a:blipFill") else { return s.to_string() };
|
||||
let Some(i) = s[b..].find(open).map(|i| i + b) else { return s.to_string() };
|
||||
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
|
||||
format!("{}{}", &s[..i], &s[j..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_without_stops_is_saved_without_stops() {
|
||||
// `a:gradFill` with no `a:gsLst` takes its stops from the style's fill in PowerPoint; saving
|
||||
// must not turn it into a gradient of its own (it used to become solid white).
|
||||
use deckcraft_model::style::{Gradient, GradientShape};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Styled".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(own.clone())),
|
||||
style: Some(deckcraft_model::ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
|
||||
assert_eq!(s.fill, Some(Fill::Gradient(own)));
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Lock and hidden state (Selection pane): what PowerPoint writes for Lock, and what it reads as locked.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::table::Table;
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Slide, defaults};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let img = image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30]));
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
fn shape(p: &mut Presentation, name: &str, x: f64) -> Shape {
|
||||
Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Slide 1 of a blank deck holds: a locked shape, an unlocked one, a hidden locked text box, a
|
||||
/// locked group (one child unlocked, one hidden), and a locked picture, connector and table.
|
||||
fn deck() -> Presentation {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("image1.png", "image/png", png());
|
||||
let mut v = vec![];
|
||||
let mut a = shape(&mut p, "Locked", 0.0);
|
||||
a.locked = true;
|
||||
v.push(a);
|
||||
v.push(shape(&mut p, "Free", 100.0));
|
||||
let mut t = shape(&mut p, "Hidden text", 200.0);
|
||||
t.text_box = true;
|
||||
t.hidden = true;
|
||||
t.locked = true;
|
||||
v.push(t);
|
||||
let mut c1 = shape(&mut p, "Child locked", 300.0);
|
||||
c1.locked = true;
|
||||
let c2 = shape(&mut p, "Child free", 400.0);
|
||||
let mut c3 = shape(&mut p, "Child hidden", 500.0);
|
||||
c3.hidden = true;
|
||||
let gx = Xfrm::new(300.0, 40.0, 280.0, 60.0);
|
||||
let mut g = Shape {
|
||||
id: ShapeId(p.alloc_id()),
|
||||
name: "Group".into(),
|
||||
xfrm: Some(gx),
|
||||
kind: ShapeKind::Group { children: vec![c1, c2, c3], child: gx },
|
||||
locked: true,
|
||||
..Default::default()
|
||||
};
|
||||
g.hidden = false;
|
||||
v.push(g);
|
||||
let mut pic = shape(&mut p, "Pic", 0.0);
|
||||
pic.kind = ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } };
|
||||
pic.locked = true;
|
||||
v.push(pic);
|
||||
let mut cx = shape(&mut p, "Conn", 100.0);
|
||||
cx.kind = ShapeKind::Connector { start: None, end: None };
|
||||
cx.geom = deckcraft_model::Geom::preset("straightConnector1");
|
||||
cx.locked = true;
|
||||
v.push(cx);
|
||||
let mut tb = shape(&mut p, "Tbl", 200.0);
|
||||
tb.kind = ShapeKind::Table(Table::new(2, 2, 80.0, 30.0));
|
||||
tb.locked = true;
|
||||
v.push(tb);
|
||||
if let Some(s) = p.slides.first_mut() {
|
||||
std::sync::Arc::make_mut(s).shapes = v;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn find<'a>(s: &'a Slide, name: &str) -> &'a Shape {
|
||||
let mut found = None;
|
||||
deckcraft_model::walk(&s.shapes, &mut |x, _| {
|
||||
if found.is_none() && x.name == name {
|
||||
found = Some(x);
|
||||
}
|
||||
});
|
||||
found.unwrap_or_else(|| panic!("no shape {name}"))
|
||||
}
|
||||
|
||||
fn entries(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
(0..z.len())
|
||||
.map(|i| {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
(f.name().to_string(), d)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rezip(es: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for (n, d) in es {
|
||||
z.start_file(n.as_str(), o).expect("start");
|
||||
z.write_all(d).expect("write");
|
||||
}
|
||||
z.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn slide1_xml(bytes: &[u8]) -> String {
|
||||
let es = entries(bytes);
|
||||
let (_, d) = es.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||
String::from_utf8(d.clone()).expect("utf8")
|
||||
}
|
||||
|
||||
/// The element following `name="…"` up to the end of the object's non-visual properties.
|
||||
fn nv_of<'a>(xml: &'a str, name: &str) -> &'a str {
|
||||
let i = xml.find(&format!("name=\"{name}\"")).unwrap_or_else(|| panic!("no {name}"));
|
||||
let rest = &xml[i..];
|
||||
let j = rest.find("<p:nvPr").unwrap_or(rest.len());
|
||||
&rest[..j]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_hidden_round_trip() {
|
||||
let p = deck();
|
||||
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
let s = &q.slides[0];
|
||||
for (name, locked, hidden) in [
|
||||
("Locked", true, false),
|
||||
("Free", false, false),
|
||||
("Hidden text", true, true),
|
||||
("Group", true, false),
|
||||
("Child locked", true, false),
|
||||
("Child free", false, false),
|
||||
("Child hidden", false, true),
|
||||
("Pic", true, false),
|
||||
("Conn", true, false),
|
||||
("Tbl", true, false),
|
||||
] {
|
||||
let sh = find(s, name);
|
||||
assert_eq!((sh.locked, sh.hidden), (locked, hidden), "{name}");
|
||||
}
|
||||
// The stacking order of the slide and of the group survives too.
|
||||
let names: Vec<&str> = s.shapes.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names, ["Locked", "Free", "Hidden text", "Group", "Pic", "Conn", "Tbl"]);
|
||||
let kids: Vec<&str> = find(s, "Group").children().iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(kids, ["Child locked", "Child free", "Child hidden"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_writes_powerpoints_full_attribute_set() {
|
||||
let xml = slide1_xml(&deckcraft_pptx::export(&deck()).expect("export"));
|
||||
let sp = r#"noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1""#;
|
||||
assert!(nv_of(&xml, "Locked").contains(&format!("<a:spLocks {sp}/>")), "{}", nv_of(&xml, "Locked"));
|
||||
assert!(nv_of(&xml, "Hidden text").contains(&format!("<a:spLocks {sp}/>")));
|
||||
assert!(nv_of(&xml, "Hidden text").contains(r#"hidden="1""#));
|
||||
assert!(!nv_of(&xml, "Free").contains("Locks"), "an unlocked shape writes no locks: {}", nv_of(&xml, "Free"));
|
||||
assert!(nv_of(&xml, "Group").contains(r#"<a:grpSpLocks noGrp="1" noUngrp="1" noRot="1" noMove="1" noResize="1"/>"#));
|
||||
assert!(nv_of(&xml, "Conn").contains(&format!("<a:cxnSpLocks {sp}/>")), "{}", nv_of(&xml, "Conn"));
|
||||
assert!(nv_of(&xml, "Pic").contains(&format!(r#"<a:picLocks noChangeAspect="1" {sp} noCrop="1"/>"#)), "{}", nv_of(&xml, "Pic"));
|
||||
assert!(nv_of(&xml, "Tbl").contains(r#"<a:graphicFrameLocks noGrp="1" noDrilldown="1" noMove="1" noResize="1"/>"#));
|
||||
}
|
||||
|
||||
/// PowerPoint reads Lock only from the whole set: a lone `noMove`/`noResize` (another tool's
|
||||
/// partial lock), or the set without `noGrp`, isn't locked.
|
||||
#[test]
|
||||
fn partial_locks_are_not_lock() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let a = shape(&mut p, "A", 0.0);
|
||||
let b = shape(&mut p, "B", 100.0);
|
||||
let c = shape(&mut p, "C", 200.0);
|
||||
let mut d = shape(&mut p, "D", 300.0);
|
||||
d.locked = true;
|
||||
if let Some(s) = p.slides.first_mut() {
|
||||
std::sync::Arc::make_mut(s).shapes = vec![a, b, c, d];
|
||||
}
|
||||
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||
let xml = String::from_utf8(slide.1.clone()).expect("utf8");
|
||||
let swap = |xml: String, name: &str, locks: &str| {
|
||||
let i = xml.find(&format!("name=\"{name}\"")).expect("name");
|
||||
let j = i + xml[i..].find("<p:cNvSpPr").expect("cNvSpPr");
|
||||
let k = j + xml[j..].find("<p:nvPr").expect("nvPr");
|
||||
format!("{}<p:cNvSpPr>{locks}</p:cNvSpPr>{}", &xml[..j], &xml[k..])
|
||||
};
|
||||
let xml = swap(xml, "A", r#"<a:spLocks noMove="1" noResize="1"/>"#);
|
||||
let xml = swap(
|
||||
xml,
|
||||
"B",
|
||||
r#"<a:spLocks noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1"/>"#,
|
||||
);
|
||||
let xml =
|
||||
swap(xml, "C", r#"<a:spLocks noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeShapeType="1"/>"#);
|
||||
slide.1 = xml.into_bytes();
|
||||
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||
let s = &q.slides[0];
|
||||
assert!(!find(s, "A").locked);
|
||||
assert!(!find(s, "B").locked);
|
||||
assert!(!find(s, "C").locked);
|
||||
assert!(find(s, "D").locked);
|
||||
}
|
||||
|
||||
/// Unlocked objects written by PowerPoint keep an empty locks element; that's not locked.
|
||||
#[test]
|
||||
fn empty_locks_element_is_unlocked() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let a = shape(&mut p, "A", 0.0);
|
||||
if let Some(s) = p.slides.first_mut() {
|
||||
std::sync::Arc::make_mut(s).shapes = vec![a];
|
||||
}
|
||||
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||
let xml = String::from_utf8(slide.1.clone()).expect("utf8").replacen("<p:cNvSpPr/>", "<p:cNvSpPr><a:spLocks/></p:cNvSpPr>", 1);
|
||||
slide.1 = xml.into_bytes();
|
||||
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||
assert!(!find(&q.slides[0], "A").locked);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Embedded (OLE) objects and their preview pictures, as other versions of PowerPoint write them.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_model::{ShapeKind, defaults};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// An exported deck with `parts` added (or replaced) and `edit` applied to every part's text.
|
||||
fn with_parts(parts: &[(&str, Vec<u8>)], edit: impl Fn(&str, String) -> String) -> Vec<u8> {
|
||||
let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(base)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
if parts.iter().any(|(n, _)| *n == name) {
|
||||
continue;
|
||||
}
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
if name.ends_with(".xml") || name.ends_with(".rels") {
|
||||
d = edit(&name, String::from_utf8(d).expect("utf8")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
for (name, data) in parts {
|
||||
out.start_file(*name, o).expect("start");
|
||||
out.write_all(data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
/// PowerPoint 2007 style: `p:oleObj spid` with no `p:pic`; the preview is the `v:imagedata` of the
|
||||
/// VML shape with that id in the slide's legacy drawing part.
|
||||
fn ole_deck(spid_in_vml: &str) -> Vec<u8> {
|
||||
let vml = format!(
|
||||
r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<v:shape id="_x0000_s9999" style='position:absolute;left:0;top:0;width:1in;height:1in'><v:imagedata o:relid="rId7" o:title=""/></v:shape>
|
||||
<v:shape id="{spid_in_vml}" style='position:absolute;left:0;top:0;width:2in;height:1in'><v:imagedata o:relid="rId1" o:title=""/></v:shape>
|
||||
</xml>"#
|
||||
);
|
||||
let vml_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/other.png"/></Relationships>"#;
|
||||
let frame = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="900" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s681987" name="Slide" r:id="rIdOle" imgW="4664160" imgH="3497400" progId="PowerPoint.Slide.8"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
|
||||
with_parts(
|
||||
&[
|
||||
("ppt/drawings/vmlDrawing1.vml", vml.into_bytes()),
|
||||
("ppt/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes().to_vec()),
|
||||
("ppt/media/preview.png", png()),
|
||||
("ppt/media/other.png", vec![1, 2, 3]),
|
||||
("ppt/embeddings/oleObject1.bin", vec![0; 16]),
|
||||
],
|
||||
|name, s| {
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
|
||||
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
|
||||
s.replacen(
|
||||
"</Relationships>",
|
||||
r#"<Relationship Id="rIdVml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdOle" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="../embeddings/oleObject1.bin"/></Relationships>"#,
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn object_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let p = deckcraft_pptx::import(bytes).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object");
|
||||
match &s.kind {
|
||||
ShapeKind::Opaque { preview, label, .. } => {
|
||||
assert_eq!(label, "PowerPoint.Slide.8");
|
||||
preview.and_then(|m| p.media(m)).map(|m| m.data.to_vec())
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_ole_object_shows_its_vml_preview() {
|
||||
assert_eq!(object_preview(&ole_deck("_x0000_s681987")), Some(png()), "the VML shape with the object's spid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ole_object_without_a_matching_vml_shape_has_no_preview() {
|
||||
assert_eq!(object_preview(&ole_deck("_x0000_s1")), None);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Legacy OLE objects whose preview picture lives in the slide's VML drawing, and ActiveX controls
|
||||
//! shown by their pictures.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_model::ShapeKind;
|
||||
use deckcraft_model::defaults;
|
||||
|
||||
const FRAME: &str = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="90" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s2050" name="Slide" r:id="rIdOle" progId="Thing.1"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
|
||||
|
||||
const VML: &str = r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><v:shape id="_x0000_s2049"><v:imagedata o:relid="rIdA"/></v:shape><v:shape id="_x0000_s2050" style='position:absolute;left:0;top:0;width:1in;height:1in'><v:imagedata o:relid="rIdB" o:title=""/></v:shape></xml>"#;
|
||||
|
||||
const VML_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdA" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/other.png"/><Relationship Id="rIdB" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/></Relationships>"#;
|
||||
|
||||
const SLIDE_RELS: &str = r#"<Relationship Id="rIdVml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/>"#;
|
||||
|
||||
/// An ActiveX control as PowerPoint writes it: a VML-only choice, and a fallback with its picture.
|
||||
const CONTROLS: &str = r#"<p:controls><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:v="urn:schemas-microsoft-com:vml" Requires="v"><p:control spid="1137" name="CheckBox1" r:id="rIdX" imgW="1933560" imgH="514440"/></mc:Choice><mc:Fallback><p:control name="CheckBox1" r:id="rIdX" imgW="1933560" imgH="514440"><p:pic><p:nvPicPr><p:cNvPr id="4" name="CheckBox1"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="12700" y="25400"/><a:ext cx="127000" cy="63500"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></p:control></mc:Fallback></mc:AlternateContent><p:control name="NoPicture" r:id="rIdY"/></p:controls>"#;
|
||||
|
||||
fn png(v: u8) -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([v, v, v])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// A new deck whose first slide holds [`FRAME`], with the VML drawing, its rels and two pictures.
|
||||
fn deck() -> Vec<u8> {
|
||||
let bytes = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
let name = f.name().to_string();
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
let s = String::from_utf8(d).expect("utf8");
|
||||
let at = s.rfind("</p:spTree>").expect("spTree");
|
||||
let s = format!("{}{FRAME}{}", &s[..at], &s[at..]);
|
||||
let at = s.rfind("</p:cSld>").expect("cSld");
|
||||
d = format!("{}{CONTROLS}{}", &s[..at], &s[at..]).into_bytes();
|
||||
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
|
||||
let s = String::from_utf8(d).expect("utf8");
|
||||
d = s.replace("</Relationships>", &format!("{SLIDE_RELS}</Relationships>")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
for (name, data) in [
|
||||
("ppt/drawings/vmlDrawing1.vml", VML.as_bytes().to_vec()),
|
||||
("ppt/drawings/_rels/vmlDrawing1.vml.rels", VML_RELS.as_bytes().to_vec()),
|
||||
("ppt/media/other.png", png(10)),
|
||||
("ppt/media/preview.png", png(200)),
|
||||
] {
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ole_object_without_a_picture_shows_its_vml_preview() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("frame");
|
||||
let ShapeKind::Opaque { preview: Some(m), label, .. } = &s.kind else { panic!("{:?}", s.kind) };
|
||||
assert_eq!(label, "Thing.1");
|
||||
let media = p.media.iter().find(|x| x.id == *m).expect("media");
|
||||
// The shape whose id is the object's spid, not the first one in the drawing.
|
||||
assert_eq!(media.name, "preview.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activex_controls_show_their_pictures() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "CheckBox1").expect("control picture");
|
||||
let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) };
|
||||
assert_eq!(p.media.iter().find(|m| m.id == fill.media).map(|m| m.name.as_str()), Some("preview.png"));
|
||||
let x = s.xfrm.expect("xfrm");
|
||||
assert_eq!((x.x, x.y, x.w, x.h), (1.0, 2.0, 10.0, 5.0));
|
||||
assert!(!p.slides[0].shapes.iter().any(|s| s.name == "NoPicture"), "a control without a picture draws nothing");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user