mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56f9cbdf49 | ||
|
|
85366b1a62 | ||
|
|
8159db8ba5 | ||
|
|
55a53f8ecb | ||
|
|
f8d54d7af4 | ||
|
|
a293b68258 | ||
|
|
dedb20890b | ||
|
|
5835f0a106 | ||
|
|
a520dd4d1d | ||
|
|
fd2f8146b4 | ||
|
|
1679836540 | ||
|
|
d8407c11f9 | ||
|
|
880b974cff | ||
|
|
b3e33928e2 | ||
|
|
d0a3d924bf | ||
|
|
81b6a9df39 | ||
|
|
d7fbbaeab1 | ||
|
|
2bf62c97e2 | ||
|
|
1bb86db54a | ||
|
|
ba56742fa9 | ||
|
|
8b160d5b03 | ||
|
|
39a8234633 | ||
|
|
98ea8d3195 | ||
|
|
2e68504e7c | ||
|
|
e6050b7d37 | ||
|
|
72d8e0ab42 | ||
|
|
2347a53845 | ||
|
|
692aa0103b | ||
|
|
ceb6e3b990 | ||
|
|
4d13652f0f | ||
|
|
235e34613b | ||
|
|
688c7ef592 | ||
|
|
a4540c1f0e | ||
|
|
0ec0cc5e81 | ||
|
|
be14248577 | ||
|
|
a98df49b21 | ||
|
|
f44857624e | ||
|
|
d556ff98b5 | ||
|
|
8fe168c07b | ||
|
|
7f1e152870 | ||
|
|
3985d768e6 | ||
|
|
e8c4c60884 | ||
|
|
a8c983ba6b | ||
|
|
788831834b | ||
|
|
ddbd71e297 | ||
|
|
b1e6867c0b | ||
|
|
c2e7f7cc3c | ||
|
|
9c3840ffa4 | ||
|
|
5c13f6ec72 | ||
|
|
c543ef350e | ||
|
|
322d85074e | ||
|
|
08d57ca15c | ||
|
|
e17fcd5598 | ||
|
|
84acb49895 | ||
|
|
4b09f35c1d | ||
|
|
57545a99c8 | ||
|
|
fa9a94a1c0 | ||
|
|
89d23ffa85 | ||
|
|
80e8a0fe53 | ||
|
|
ab6f511479 | ||
|
|
b48870989c | ||
|
|
b663be69b0 | ||
|
|
34d5197370 | ||
|
|
5fed177cbe | ||
|
|
baff6a2ff2 | ||
|
|
68aff057bf | ||
|
|
21f1703994 |
@@ -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,48 +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 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.deckcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.deckcraft" and m["command"] == "deckcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
# 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
-403
@@ -1,16 +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).
|
||||
#
|
||||
# The jobs that sign (macOS, Windows) and the one that drafts the release 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. The
|
||||
# other jobs sign nothing and get no secrets, so a workflow_dispatch from another branch is a dry
|
||||
# run: they build, the signed jobs are refused by the environment, and no release is drafted.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -24,400 +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
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: no secrets needed, so branch dry runs get past it.
|
||||
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-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
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 }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing (x64 signtool / Azure
|
||||
# Trusted Signing) and WiX (-arch arm64) run as above. package.ps1 checks the PE machine
|
||||
# type and skips the CLI smoke test, since an ARM64 exe can't run on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-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 }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
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 zsync
|
||||
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
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm, with the
|
||||
# craft-fonts embedded); no Rust build here. packaging/linux/flatpak/ai.storyteller.deckcraft.yml
|
||||
# stays the from-source manifest for Flathub. 24.04 for a newer flatpak-builder (1.4) than
|
||||
# 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which can fail without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/deckcraft-$DECKCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
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
|
||||
tar -tzf dist/release/deckcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/deckcraft$|/bin/deckcraft-cli$|/share/applications/ai\.storyteller\.deckcraft\.desktop$'
|
||||
- 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
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
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
|
||||
# Every build is required, so the draft always has the full set the README lists. Needing the
|
||||
# signed jobs also keeps branch dry runs from drafting a release.
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
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 }}
|
||||
|
||||
@@ -158,8 +158,21 @@ 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)
|
||||
|
||||
|
||||
@@ -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
+297
-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.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -885,7 +896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -894,7 +905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -903,14 +914,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -919,14 +930,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -948,7 +959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -960,7 +971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -971,7 +982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -979,7 +990,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -988,7 +999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -997,7 +1008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1005,11 +1016,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1019,7 +1030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1035,9 +1046,17 @@ 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.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1048,15 +1067,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
@@ -1071,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1087,35 +1106,39 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.3.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.3.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.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1137,7 +1160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1146,7 +1169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1203,6 +1226,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"
|
||||
@@ -1336,6 +1370,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -1634,6 +1669,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"
|
||||
@@ -2048,6 +2092,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"
|
||||
@@ -2349,6 +2521,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"
|
||||
@@ -3173,6 +3351,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"
|
||||
@@ -4197,6 +4384,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"
|
||||
@@ -4384,6 +4581,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"
|
||||
@@ -4700,6 +4915,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"
|
||||
@@ -5357,6 +5588,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"
|
||||
@@ -5428,7 +5665,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5603,6 +5840,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.3.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"
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
## 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
|
||||
@@ -103,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
|
||||
@@ -121,10 +154,12 @@ 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
|
||||
|
||||
@@ -140,6 +175,21 @@ scorecard.
|
||||
|
||||
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 |
|
||||
@@ -157,6 +207,8 @@ Installers and executables are code-signed.
|
||||
| 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 |
|
||||
|
||||
+129
-82
@@ -1,100 +1,147 @@
|
||||
# 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):** 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 | Done for macOS + Windows signing: v0.1.0 published (all platforms built); installers still to be hand-checked on each OS | 2 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 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 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-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 |
+137
-11
@@ -2,23 +2,140 @@
|
||||
"schema": 1,
|
||||
"repo": "storytold/deckcraft",
|
||||
"branch": "main",
|
||||
"head": "9da767d5f762119c9bad9deb94001de36fde01c9",
|
||||
"head_date": "2026-10-08T00:23:52Z",
|
||||
"commits": 22,
|
||||
"head": "4b09f35c1d6fa50e2d94243a5d65d98fe49558e0",
|
||||
"head_date": "2026-10-09T14:48:26Z",
|
||||
"commits": 41,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 22,
|
||||
"lines_added": 123461,
|
||||
"lines_deleted": 3662,
|
||||
"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-08T00:23:52Z"
|
||||
"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": [
|
||||
@@ -27,9 +144,18 @@
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 21,
|
||||
"lines_added": 123454,
|
||||
"lines_deleted": 3662
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,3 +21,19 @@
|
||||
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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -71,7 +71,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)),
|
||||
@@ -367,12 +384,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();
|
||||
|
||||
@@ -246,12 +246,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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -228,7 +228,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 +238,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"),
|
||||
@@ -535,21 +549,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 +591,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
@@ -141,6 +142,9 @@ 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>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -158,6 +162,7 @@ impl DocState {
|
||||
interaction: None,
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
title,
|
||||
missing_fonts: Vec::new(),
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -300,6 +305,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 +316,8 @@ 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>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -331,9 +340,11 @@ 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(),
|
||||
}
|
||||
}
|
||||
/// A session with one new presentation open.
|
||||
|
||||
@@ -366,3 +366,204 @@ 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);
|
||||
}
|
||||
|
||||
+34
-11
@@ -434,6 +434,10 @@ impl Session {
|
||||
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;
|
||||
@@ -789,7 +793,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 +859,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 +1262,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 +1275,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,574 @@
|
||||
//! 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 crate::dib::Rgba;
|
||||
use crate::geom::{Op, P};
|
||||
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;
|
||||
const WINDING: u32 = 2;
|
||||
/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing.
|
||||
const R2_NOP: u32 = 11;
|
||||
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, Copy, PartialEq, Debug)]
|
||||
pub(crate) struct Brush {
|
||||
pub style: u32,
|
||||
pub color: [u8; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum Obj {
|
||||
Pen(Pen),
|
||||
Brush(Brush),
|
||||
/// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// 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 },
|
||||
Bitmap { rect: [f64; 4], img: Rgba },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Saved {
|
||||
map: Mapping,
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
even_odd: bool,
|
||||
nop: bool,
|
||||
}
|
||||
|
||||
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,
|
||||
/// The mix mode is R2_NOP: shapes draw nothing.
|
||||
nop: bool,
|
||||
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 { style: BS_SOLID, color: [255, 255, 255, 255] },
|
||||
even_odd: true,
|
||||
nop: false,
|
||||
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>(),
|
||||
Raw::Bitmap { img, .. } => img.px.len(),
|
||||
};
|
||||
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: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy.
|
||||
pub fn set_rop2(&mut self, mode: u32) {
|
||||
self.nop = mode == R2_NOP;
|
||||
}
|
||||
|
||||
/// 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, even_odd: self.even_odd, nop: self.nop });
|
||||
} 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.nop = s.nop;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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).copied().flatten() {
|
||||
Some(Obj::Pen(p)) => self.pen = p,
|
||||
Some(Obj::Brush(b)) => self.brush = b,
|
||||
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 { style: BS_SOLID, color: [v, v, v, 255] };
|
||||
match i {
|
||||
0 | 18 => self.brush = Brush { style: BS_SOLID, color: white },
|
||||
1 => self.brush = gray(192),
|
||||
2 => self.brush = gray(128),
|
||||
3 => self.brush = gray(64),
|
||||
4 => self.brush = Brush { style: BS_SOLID, color: black },
|
||||
5 => self.brush = Brush { style: BS_NULL, color: 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 },
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_color(&self) -> Option<[u8; 4]> {
|
||||
(self.brush.style == BS_SOLID).then_some(self.brush.color)
|
||||
}
|
||||
|
||||
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.nop {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_color() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
if (fill.is_none() && stroke.is_none()) || dev.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let even_odd = self.even_odd;
|
||||
self.push(Raw::Path { ops: dev, fill, stroke, even_odd });
|
||||
}
|
||||
|
||||
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.nop {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_color() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
if ops.len() < 2 || (fill.is_none() && stroke.is_none()) {
|
||||
return;
|
||||
}
|
||||
let even_odd = self.even_odd;
|
||||
self.push(Raw::Path { ops, fill, stroke, even_odd });
|
||||
}
|
||||
|
||||
/// Fills a logical rectangle `[l, t, r, b]` with the current brush (nothing when it is not solid).
|
||||
pub fn fill_brush_rect(&mut self, rect: [f64; 4]) {
|
||||
if let Some(color) = self.fill_color() {
|
||||
self.fill_rect_with(rect, color);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,315 @@
|
||||
//! 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 crate::bytes::Bytes;
|
||||
use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
|
||||
use crate::dib::{self, Blend};
|
||||
use crate::geom::{self, ArcKind, P};
|
||||
use crate::{Error, Frame, MAX_PATH_OPS};
|
||||
|
||||
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),
|
||||
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)?;
|
||||
cv.add_object(Some(slot), Obj::Brush(Brush { style, color: colorref(color) }));
|
||||
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,208 @@
|
||||
//! 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 and RGBA
|
||||
//! bitmaps, in a picture frame with y down.
|
||||
//!
|
||||
//! 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 wmf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use bytes::Bytes;
|
||||
use canvas::Raw;
|
||||
use geom::{Op, P};
|
||||
pub use place::{MAX_BITMAP_SIDE, PlacedItem};
|
||||
|
||||
/// 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> },
|
||||
}
|
||||
|
||||
#[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) {
|
||||
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);
|
||||
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 })
|
||||
}
|
||||
});
|
||||
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, .. } => {
|
||||
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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! 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 },
|
||||
}
|
||||
|
||||
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::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,273 @@
|
||||
//! 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 crate::bytes::Bytes;
|
||||
use crate::canvas::{Brush, Canvas, Obj, Pen, colorref};
|
||||
use crate::dib;
|
||||
use crate::geom::{self, ArcKind, P};
|
||||
use crate::{Error, Frame, MAX_PATH_OPS};
|
||||
|
||||
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
|
||||
const DEFAULT_INCH: f64 = 1440.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);
|
||||
}
|
||||
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),
|
||||
// Fonts, palettes, pattern brushes and regions take a handle slot too, so later SELECTOBJECT
|
||||
// handles count them; selecting one draws nothing.
|
||||
0x02FB | 0x00F7 | 0x01F9 | 0x0142 | 0x06FF => {
|
||||
cv.add_object(None, Obj::Other);
|
||||
Some(())
|
||||
}
|
||||
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 (hatch is not drawn).
|
||||
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
|
||||
let style = r.u16(6)?;
|
||||
let color = r.u32(8)?;
|
||||
cv.add_object(None, Obj::Brush(Brush { style: u32::from(style), color: colorref(color) }));
|
||||
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(())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+38
-15
@@ -488,6 +488,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 +511,7 @@ impl Default for Slide {
|
||||
animations: vec![],
|
||||
notes: TextBody::default(),
|
||||
comments: vec![],
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
}
|
||||
}
|
||||
@@ -596,6 +601,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 +650,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 +670,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 +798,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>,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -219,6 +223,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 +271,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())];
|
||||
|
||||
@@ -132,3 +132,58 @@ 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));
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
+167
-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();
|
||||
|
||||
@@ -180,6 +180,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 +238,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 +356,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 +371,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 +410,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 +418,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -494,6 +494,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,81 @@
|
||||
//! 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..])
|
||||
}
|
||||
@@ -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,64 @@
|
||||
//! Placeholder inheritance in decks written by PowerPoint.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_model::{Geom, 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()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placeholder_without_geometry_takes_its_layouts() {
|
||||
// PowerPoint leaves a slide placeholder's spPr empty and draws it with the layout
|
||||
// placeholder's geometry (a picture placeholder cut into a shape keeps the shape).
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = p.slides[0].layout;
|
||||
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
||||
let l = m.layouts.iter_mut().find(|l| l.id == layout).expect("layout");
|
||||
for s in &mut l.shapes {
|
||||
if s.ph.is_some() {
|
||||
s.geom = Geom::Preset { name: "ellipse".into(), adj: vec![] };
|
||||
}
|
||||
}
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
// Strip the slide's own geometry, as PowerPoint writes inheriting placeholders.
|
||||
let bare = edit_slides(&bytes, |s| {
|
||||
let mut s = s;
|
||||
while let Some(i) = s.find("<a:prstGeom") {
|
||||
let Some(j) = s[i..].find("</a:prstGeom>").map(|j| j + i + "</a:prstGeom>".len()) else { break };
|
||||
s.replace_range(i..j, "");
|
||||
}
|
||||
s
|
||||
});
|
||||
let q = deckcraft_pptx::import(&bare).expect("import");
|
||||
let phs: Vec<_> = q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).collect();
|
||||
assert!(!phs.is_empty());
|
||||
for s in phs {
|
||||
assert!(matches!(&s.geom, Geom::Preset { name, .. } if name == "ellipse"), "{:?}", s.geom);
|
||||
}
|
||||
// A placeholder with its own geometry keeps it.
|
||||
let first = p.slides[0].id;
|
||||
for s in &mut p.slide_mut(first).expect("slide").shapes {
|
||||
if s.ph.is_some() {
|
||||
s.geom = Geom::Preset { name: "triangle".into(), adj: vec![] };
|
||||
}
|
||||
}
|
||||
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
assert!(q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).all(|s| matches!(&s.geom, Geom::Preset { name, .. } if name == "triangle")));
|
||||
}
|
||||
@@ -686,3 +686,174 @@ fn bisect_slide_variants() {
|
||||
std::fs::write(format!("{dir}/v-only{k}.pptx"), one(s)).expect("write");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraphs_script_fonts_and_just_low_round_trip() {
|
||||
let mut p = rich_deck();
|
||||
let fonts = &mut Arc::make_mut(&mut p.masters[0]).theme.fonts;
|
||||
fonts.minor.cs = "Noto Naskh Arabic".into();
|
||||
fonts.minor.scripts = vec![
|
||||
deckcraft_model::theme::ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() },
|
||||
deckcraft_model::theme::ScriptFont { script: "Hebr".into(), typeface: "Noto Sans Hebrew".into() },
|
||||
];
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
let sh = slide.shapes.iter_mut().find(|s| s.text.is_some()).expect("a text shape");
|
||||
let body = sh.text.as_mut().expect("text");
|
||||
body.paragraphs = vec![Paragraph { runs: vec![Run::new("سنة 2024 (Retrait) فقط")], ..Default::default() }];
|
||||
body.paragraphs[0].props.rtl = Some(true);
|
||||
body.paragraphs[0].props.align = Some(Align::JustLow);
|
||||
let name = sh.name.clone();
|
||||
let q = round(&p);
|
||||
assert_eq!(q.masters[0].theme.fonts.minor.scripts, p.masters[0].theme.fonts.minor.scripts);
|
||||
assert_eq!(q.masters[0].theme.fonts.minor.script("arab"), Some("Noto Naskh Arabic"));
|
||||
let para = &by_name(&q.slides[0], &name).text.as_ref().expect("text").paragraphs[0];
|
||||
assert_eq!(para.props.rtl, Some(true));
|
||||
assert_eq!(para.props.align, Some(Align::JustLow));
|
||||
assert_eq!(para.runs[0].text, "سنة 2024 (Retrait) فقط");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_fields_keep_their_type_and_saved_text() {
|
||||
// An automatic date is a field (its saved text is what readers without a clock show); a fixed
|
||||
// date is plain text.
|
||||
let mut p = Presentation::default();
|
||||
let mut auto = shape(&mut p, "Auto date", 10.0, 10.0, 200.0, 40.0);
|
||||
let mut body = TextBody::from_text("");
|
||||
body.paragraphs[0].runs =
|
||||
vec![Run { text: "March 3, 2025".into(), props: RunProps::default(), kind: RunKind::Field { field: "datetime4".into() } }];
|
||||
auto.text = Some(body);
|
||||
let mut fixed = shape(&mut p, "Fixed date", 10.0, 60.0, 200.0, 40.0);
|
||||
fixed.text = Some(TextBody::from_text("Launch day"));
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![auto, fixed];
|
||||
let q = round(&p);
|
||||
for name in ["Auto date", "Fixed date"] {
|
||||
assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_map_overrides_round_trip() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
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;
|
||||
std::sync::Arc::make_mut(&mut p.masters[0]).layouts.iter_mut().find(|l| l.id == layout).expect("layout").color_map = Some(dark.clone());
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").color_map = Some(dark.clone());
|
||||
let q = round(&p);
|
||||
let get = |m: &Option<Vec<(String, String)>>, k: &str| m.as_ref().and_then(|m| m.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()));
|
||||
let ql = q.masters[0].layouts.iter().find(|l| l.color_map.is_some()).expect("the layout keeps its override");
|
||||
assert_eq!(get(&ql.color_map, "bg1").as_deref(), Some("dk1"));
|
||||
assert_eq!(get(&q.slides[0].color_map, "tx1").as_deref(), Some("lt1"));
|
||||
// Layouts and slides without one keep following the master.
|
||||
assert!(q.masters[0].layouts.iter().filter(|l| l.color_map.is_none()).count() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_duotone_round_trips() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128]));
|
||||
let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0);
|
||||
let mut pf = PictureFill { media: img, ..Default::default() };
|
||||
pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200))));
|
||||
pic.kind = deckcraft_model::ShapeKind::Picture { fill: pf };
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(pic);
|
||||
let q = round(&p);
|
||||
let sh = by_name(&q.slides[0], "Duotone");
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("picture") };
|
||||
let (d, l) = fill.adjust.duotone.as_ref().expect("duotone kept");
|
||||
assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 }));
|
||||
assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_break_options_round_trip() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let first = p.slides[0].id;
|
||||
let s = p.slide_mut(first).expect("slide");
|
||||
let t = s.shapes[0].text.insert(TextBody::from_text("x"));
|
||||
t.paragraphs[0].props.latin_line_break = Some(true);
|
||||
t.paragraphs[0].props.hanging_punct = Some(false);
|
||||
let q = round(&p);
|
||||
let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props;
|
||||
assert_eq!(props.latin_line_break, Some(true));
|
||||
assert_eq!(props.hanging_punct, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_fill_round_trips() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let mut t = Table::new(2, 2, 200.0, 40.0);
|
||||
t.background = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 112, 192))));
|
||||
let mut sh = shape(&mut p, "Filled table", 10.0, 10.0, 200.0, 80.0);
|
||||
sh.kind = deckcraft_model::ShapeKind::Table(t);
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(sh);
|
||||
let q = round(&p);
|
||||
let deckcraft_model::ShapeKind::Table(t) = &by_name(&q.slides[0], "Filled table").kind else { panic!("table") };
|
||||
assert!(
|
||||
matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wordart_warps_and_their_adjusts_round_trip() {
|
||||
let mut p = Presentation::default();
|
||||
let cases: [(&str, &str, Vec<f64>); 5] = [
|
||||
("Arch pour", "textArchUpPour", vec![13_500_000.0, 30_000.0]),
|
||||
("Wave", "textWave1", vec![18_000.0, -5_000.0]),
|
||||
("Inflate", "textInflate", vec![30_000.0]),
|
||||
("Arch", "textArchUp", vec![]),
|
||||
("Ring", "textRingInside", vec![75_000.0]),
|
||||
];
|
||||
let mut shapes = vec![];
|
||||
for (i, (name, warp, adj)) in cases.iter().enumerate() {
|
||||
let mut s = shape(&mut p, name, 10.0, 10.0 + 50.0 * i as f64, 300.0, 40.0);
|
||||
let mut body = TextBody::from_text("WordArt");
|
||||
body.body.warp = Some((*warp).into());
|
||||
body.body.warp_adj = adj.clone();
|
||||
s.text = Some(body);
|
||||
shapes.push(s);
|
||||
}
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = shapes;
|
||||
let q = round(&p);
|
||||
for (name, warp, adj) in &cases {
|
||||
let b = &by_name(&q.slides[0], name).text.as_ref().expect("text").body;
|
||||
assert_eq!(b.warp.as_deref(), Some(*warp), "{name}");
|
||||
assert_eq!(&b.warp_adj, adj, "{name}");
|
||||
}
|
||||
// The single-adjust preset is written with the guide name `adj`, two-adjust ones with
|
||||
// `adj1`/`adj2`, and a preset with no adjusts given gets an empty list.
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read");
|
||||
assert!(xml.contains(r#"<a:prstTxWarp prst="textInflate"><a:avLst><a:gd name="adj" fmla="val 30000"/></a:avLst></a:prstTxWarp>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:gd name="adj1" fmla="val 13500000"/><a:gd name="adj2" fmla="val 30000"/>"#));
|
||||
assert!(xml.contains(r#"<a:prstTxWarp prst="textArchUp"><a:avLst/></a:prstTxWarp>"#));
|
||||
// A file that gives only `adj2` keeps the preset's default for `adj1`; hostile values are
|
||||
// read as numbers or dropped.
|
||||
let edited = xml
|
||||
.replace(r#"<a:gd name="adj1" fmla="val 13500000"/>"#, "")
|
||||
.replace(r#"<a:gd name="adj" fmla="val 30000"/>"#, r#"<a:gd name="adj" fmla="val NaN"/>"#);
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
std::io::Read::read_to_end(&mut f, &mut data).expect("read");
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
data = edited.clone().into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
std::io::Write::write_all(&mut out, &data).expect("write");
|
||||
}
|
||||
let bytes = out.finish().expect("finish").into_inner();
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let pour = &by_name(&q.slides[0], "Arch pour").text.as_ref().expect("text").body;
|
||||
assert_eq!(pour.warp_adj, vec![10_800_000.0, 30_000.0]);
|
||||
let inflate = &by_name(&q.slides[0], "Inflate").text.as_ref().expect("text").body;
|
||||
assert_eq!(inflate.warp.as_deref(), Some("textInflate"));
|
||||
assert!(inflate.warp_adj.is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! `sniff` must recognise a package wherever the writer put `[Content_Types].xml`: recent
|
||||
//! PowerPoint builds store it as the last zip entry, well past the first few kilobytes.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
fn sample_pptx() -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
|
||||
let p = s.doc().expect("doc").doc.as_ref().clone();
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
/// Re-pack `bytes` with `[Content_Types].xml` moved to the end and a padding part in front, so
|
||||
/// the content-types name first appears far beyond any fixed-size prefix.
|
||||
fn content_types_last(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
out.start_file("ppt/media/padding.bin", stored).expect("start");
|
||||
out.write_all(&vec![0u8; 64 * 1024]).expect("padding");
|
||||
let names: Vec<String> = (0..src.len()).map(|i| src.by_index(i).expect("entry").name().to_string()).collect();
|
||||
for name in names.iter().filter(|n| *n != "[Content_Types].xml").chain(std::iter::once(&"[Content_Types].xml".to_string())) {
|
||||
let mut data = Vec::new();
|
||||
src.by_name(name).expect("by_name").read_to_end(&mut data).expect("read");
|
||||
out.start_file(name, SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_accepts_content_types_anywhere_in_the_archive() {
|
||||
let bytes = sample_pptx();
|
||||
assert!(deckcraft_pptx::sniff(&bytes));
|
||||
|
||||
let moved = content_types_last(&bytes);
|
||||
let first_4k = &moved[..4096.min(moved.len())];
|
||||
assert!(!first_4k.windows(19).any(|w| w == b"[Content_Types].xml"), "test setup: name must not be in the prefix");
|
||||
assert!(deckcraft_pptx::sniff(&moved));
|
||||
let p = deckcraft_pptx::import(&moved).expect("import");
|
||||
assert!(!p.slides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_rejects_non_packages() {
|
||||
assert!(!deckcraft_pptx::sniff(b""));
|
||||
assert!(!deckcraft_pptx::sniff(b"PK"));
|
||||
assert!(!deckcraft_pptx::sniff(b"%PDF-1.7 [Content_Types].xml"));
|
||||
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
out.start_file("hello.txt", SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(b"not an office package").expect("write");
|
||||
let plain_zip = out.finish().expect("finish").into_inner();
|
||||
assert!(!deckcraft_pptx::sniff(&plain_zip));
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Fills and styles read from decks written by other tools.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::Fill;
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
const STYLE_GUID: &str = "{ABCDEF01-2345-4678-9ABC-DEF012345678}";
|
||||
|
||||
/// A table style definition as PowerPoint writes one: red table background, see-through white
|
||||
/// bands, 2 pt blue outer borders and no inside lines.
|
||||
fn style_xml() -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{STYLE_GUID}"><a:tblStyle styleId="{STYLE_GUID}" styleName="Probe Style"><a:tblBg><a:fillRef idx="1"><a:srgbClr val="FF0000"/></a:fillRef></a:tblBg><a:wholeTbl><a:tcTxStyle b="on"><a:srgbClr val="00FF00"/></a:tcTxStyle><a:tcStyle><a:tcBdr><a:left><a:ln w="25400"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln></a:left><a:insideH><a:ln><a:noFill/></a:ln></a:insideH></a:tcBdr><a:fill><a:noFill/></a:fill></a:tcStyle></a:wholeTbl><a:band1H><a:tcStyle><a:tcBdr/><a:fill><a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="20000"/></a:srgbClr></a:solidFill></a:fill></a:tcStyle></a:band1H></a:tblStyle></a:tblStyleLst>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// An exported deck with a 3x2 table using the probe style.
|
||||
fn table_deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0);
|
||||
t.style = "medium2-accent1".into();
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Styled".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 300.0, 90.0)),
|
||||
kind: deckcraft_model::ShapeKind::Table(t),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
// Point the table at the probe style and replace the style part.
|
||||
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/tableStyles.xml" {
|
||||
d = style_xml().into_bytes();
|
||||
} else if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
let s = String::from_utf8(d).expect("utf8");
|
||||
let start = s.find("<a:tableStyleId>").map(|i| i + "<a:tableStyleId>".len());
|
||||
let end = s.find("</a:tableStyleId>");
|
||||
d = match (start, end) {
|
||||
(Some(a), Some(b)) => format!("{}{}{}", &s[..a], STYLE_GUID, &s[b..]).into_bytes(),
|
||||
_ => s.into_bytes(),
|
||||
};
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_styles_the_file_defines_are_read_used_and_kept() {
|
||||
use deckcraft_model::table::{TableBorder, TablePart};
|
||||
let p = deckcraft_pptx::import(&table_deck()).expect("import");
|
||||
let def = p.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("the probe style is read");
|
||||
assert_eq!(def.name, "Probe Style");
|
||||
let bg = def.background.as_ref().expect("table background");
|
||||
assert!(matches!(bg.fill_ref, Some((1, _))));
|
||||
let whole = def.part(TablePart::WholeTbl).expect("whole table");
|
||||
assert_eq!(whole.bold, Some(true));
|
||||
assert!(whole.text_color.is_some());
|
||||
assert!(matches!(&whole.borders[0], Some(TableBorder::Line { line }) if line.width == Some(2.0)));
|
||||
assert!(matches!(&whole.borders[4], Some(TableBorder::Line { line }) if matches!(line.fill, Some(Fill::None))));
|
||||
assert!(matches!(def.part(TablePart::Band1H).and_then(|b| b.fill.clone()), Some(Fill::Solid { .. })));
|
||||
// The table keeps the style's GUID, so it's drawn from the definition.
|
||||
let s = p.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("table");
|
||||
let deckcraft_model::ShapeKind::Table(t) = &s.kind else { panic!("table") };
|
||||
assert_eq!(t.style, STYLE_GUID);
|
||||
// Saving writes the definition back, unchanged.
|
||||
let again = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&again).expect("reimport");
|
||||
let def2 = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("kept on save");
|
||||
assert_eq!(def2.parts, def.parts);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
deckcraft-geom = { workspace = true }
|
||||
deckcraft-color = { workspace = true }
|
||||
deckcraft-metafile = { workspace = true }
|
||||
deckcraft-model = { workspace = true }
|
||||
deckcraft-fonts = { workspace = true }
|
||||
deckcraft-text = { workspace = true }
|
||||
@@ -19,6 +20,7 @@ vello_common = { workspace = true }
|
||||
image = { workspace = true }
|
||||
log = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw
|
||||
//! them: layers of glyph outlines filled with solid colours or gradients.
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_fonts::FontFace;
|
||||
use kurbo::{Affine, BezPath, Point, Rect};
|
||||
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
|
||||
use skrifa::raw::types::BoundingBox;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
|
||||
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
|
||||
/// False when the glyph has no colour version, so the caller draws the plain outline.
|
||||
pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool {
|
||||
let Some(font) = face.skrifa() else { return false };
|
||||
let glyphs = font.color_glyphs();
|
||||
let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false };
|
||||
let palette: Vec<Rgba> = font
|
||||
.color_palettes()
|
||||
.get(0)
|
||||
.map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect())
|
||||
.unwrap_or_default();
|
||||
// Font units are y up; the slide is y down.
|
||||
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
|
||||
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
|
||||
let ok = glyph.paint(face.location(), &mut p).is_ok();
|
||||
// A malformed glyph may leave layers open: close them all.
|
||||
while let Some(open) = p.opened.pop() {
|
||||
if open {
|
||||
p.ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
p.ctx.pop_layer();
|
||||
ok
|
||||
}
|
||||
|
||||
struct Painter<'a> {
|
||||
ctx: &'a mut RenderContext,
|
||||
face: &'a FontFace,
|
||||
base: Affine,
|
||||
/// Transforms pushed by the glyph (font units), innermost last.
|
||||
stack: Vec<Affine>,
|
||||
palette: Vec<Rgba>,
|
||||
fg: Rgba,
|
||||
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
|
||||
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
|
||||
opened: Vec<bool>,
|
||||
}
|
||||
|
||||
fn affine(t: &Transform) -> Affine {
|
||||
Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
|
||||
}
|
||||
|
||||
fn pt(p: skrifa::raw::types::Point<f32>) -> Point {
|
||||
Point::new(p.x as f64, p.y as f64)
|
||||
}
|
||||
|
||||
fn extend(e: Extend) -> peniko::Extend {
|
||||
match e {
|
||||
Extend::Repeat => peniko::Extend::Repeat,
|
||||
Extend::Reflect => peniko::Extend::Reflect,
|
||||
_ => peniko::Extend::Pad,
|
||||
}
|
||||
}
|
||||
|
||||
fn blend(m: CompositeMode) -> peniko::BlendMode {
|
||||
use peniko::{Compose, Mix};
|
||||
let (mix, compose) = match m {
|
||||
CompositeMode::Clear => (Mix::Normal, Compose::Clear),
|
||||
CompositeMode::Src => (Mix::Normal, Compose::Copy),
|
||||
CompositeMode::Dest => (Mix::Normal, Compose::Dest),
|
||||
CompositeMode::DestOver => (Mix::Normal, Compose::DestOver),
|
||||
CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn),
|
||||
CompositeMode::DestIn => (Mix::Normal, Compose::DestIn),
|
||||
CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut),
|
||||
CompositeMode::DestOut => (Mix::Normal, Compose::DestOut),
|
||||
CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop),
|
||||
CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop),
|
||||
CompositeMode::Xor => (Mix::Normal, Compose::Xor),
|
||||
CompositeMode::Plus => (Mix::Normal, Compose::Plus),
|
||||
CompositeMode::Screen => (Mix::Screen, Compose::SrcOver),
|
||||
CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver),
|
||||
CompositeMode::Darken => (Mix::Darken, Compose::SrcOver),
|
||||
CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver),
|
||||
CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver),
|
||||
CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver),
|
||||
CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver),
|
||||
CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver),
|
||||
CompositeMode::Difference => (Mix::Difference, Compose::SrcOver),
|
||||
CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver),
|
||||
CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver),
|
||||
CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver),
|
||||
CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver),
|
||||
CompositeMode::HslColor => (Mix::Color, Compose::SrcOver),
|
||||
CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver),
|
||||
_ => (Mix::Normal, Compose::SrcOver),
|
||||
};
|
||||
peniko::BlendMode::new(mix, compose)
|
||||
}
|
||||
|
||||
impl Painter<'_> {
|
||||
fn current(&self) -> Affine {
|
||||
self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY)
|
||||
}
|
||||
|
||||
fn color(&self, index: u16, alpha: f32) -> peniko::Color {
|
||||
let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) };
|
||||
let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
|
||||
peniko::Color::from_rgba8(c.r, c.g, c.b, a)
|
||||
}
|
||||
|
||||
fn stops(&self, stops: &[ColorStop]) -> Vec<peniko::ColorStop> {
|
||||
stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect()
|
||||
}
|
||||
|
||||
/// The glyph outline in font units, y up.
|
||||
fn outline(&self, gid: GlyphId) -> BezPath {
|
||||
let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32());
|
||||
Affine::scale_non_uniform(1.0, -1.0) * (*p).clone()
|
||||
}
|
||||
|
||||
fn push_clip(&mut self, path: &BezPath) {
|
||||
let open = self.opened.len() < MAX_DEPTH;
|
||||
if open {
|
||||
self.ctx.set_transform(self.current());
|
||||
self.ctx.push_clip_layer(path);
|
||||
}
|
||||
self.opened.push(open);
|
||||
}
|
||||
|
||||
fn pop(&mut self) {
|
||||
if self.opened.pop() == Some(true) {
|
||||
self.ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the open clip area (a large box in the current transform) with `paint`.
|
||||
fn cover(&mut self, paint: impl Into<vello_cpu::PaintType>) {
|
||||
self.ctx.set_transform(self.current());
|
||||
self.ctx.set_paint(paint);
|
||||
self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5));
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorPainter for Painter<'_> {
|
||||
fn push_transform(&mut self, t: Transform) {
|
||||
let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t);
|
||||
self.stack.push(next);
|
||||
}
|
||||
|
||||
fn pop_transform(&mut self) {
|
||||
if self.stack.len() > 1 {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn push_clip_glyph(&mut self, gid: GlyphId) {
|
||||
let path = self.outline(gid);
|
||||
self.push_clip(&path);
|
||||
}
|
||||
|
||||
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
|
||||
let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
|
||||
self.push_clip(&kurbo::Shape::to_path(&r, 0.1));
|
||||
}
|
||||
|
||||
fn pop_clip(&mut self) {
|
||||
self.pop();
|
||||
}
|
||||
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
match brush {
|
||||
Brush::Solid { palette_index, alpha } => {
|
||||
let c = self.color(palette_index, alpha);
|
||||
self.cover(c);
|
||||
}
|
||||
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0))
|
||||
.with_stops(self.stops(color_stops).as_slice())
|
||||
.with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians())
|
||||
.with_stops(self.stops(color_stops).as_slice())
|
||||
.with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_layer(&mut self, mode: CompositeMode) {
|
||||
let open = self.opened.len() < MAX_DEPTH;
|
||||
if open {
|
||||
self.ctx.push_blend_layer(blend(mode));
|
||||
}
|
||||
self.opened.push(open);
|
||||
}
|
||||
|
||||
fn pop_layer(&mut self) {
|
||||
self.pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Compound outlines (`a:ln/@cmpd`): double, thick-thin, thin-thick and triple lines.
|
||||
//!
|
||||
//! PowerPoint splits the line's width into bands across the path, measured from its left side
|
||||
//! (looking along the path) to its right, as these shares of the width (read from PowerPoint's
|
||||
//! export of 24 pt lines of each kind):
|
||||
//! - double: 1/3 line, 1/3 gap, 1/3 line;
|
||||
//! - thin-thick: 1/5 line, 1/5 gap, 3/5 line; thick-thin the mirror;
|
||||
//! - triple: 1/6 line, 1/6 gap, 1/3 line, 1/6 gap, 1/6 line.
|
||||
//!
|
||||
//! On a shape drawn clockwise (all presets) the left side is the outside.
|
||||
|
||||
use deckcraft_model::style::Compound;
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, PathEl, PathSeg, Point, Vec2};
|
||||
|
||||
/// The bands of `c` as (from, to) shares of the width, left side first.
|
||||
pub fn bands(c: Compound) -> &'static [(f64, f64)] {
|
||||
match c {
|
||||
Compound::Single => &[(0.0, 1.0)],
|
||||
Compound::Double => &[(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0)],
|
||||
Compound::ThinThick => &[(0.0, 0.2), (0.4, 1.0)],
|
||||
Compound::ThickThin => &[(0.0, 0.6), (0.8, 1.0)],
|
||||
Compound::Triple => &[(0.0, 1.0 / 6.0), (2.0 / 6.0, 4.0 / 6.0), (5.0 / 6.0, 1.0)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit normal pointing to the right of travel (y down), or None for a zero-length tangent.
|
||||
fn right(t: Vec2) -> Option<Vec2> {
|
||||
let l = t.hypot();
|
||||
(l > 1e-12 && l.is_finite()).then(|| Vec2::new(-t.y, t.x) / l)
|
||||
}
|
||||
|
||||
/// `path` moved `d` to the right of travel (negative: to the left). Line corners meet at their
|
||||
/// miter point (bevelled when sharper than about 11°); curves are offset by kurbo; other joins are
|
||||
/// bridged by a straight line.
|
||||
pub fn offset(path: &BezPath, d: f64, tolerance: f64) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
if d.abs() < 1e-9 {
|
||||
return path.clone();
|
||||
}
|
||||
// Each subpath as segments, offset one by one.
|
||||
let mut segs: Vec<PathSeg> = Vec::new();
|
||||
let mut closed = false;
|
||||
let flush = |segs: &mut Vec<PathSeg>, closed: bool, out: &mut BezPath| {
|
||||
let pieces: Vec<(PathSeg, BezPath)> = segs.iter().filter_map(|s| offset_seg(*s, d, tolerance).map(|p| (*s, p))).collect();
|
||||
let begin = out.elements().len();
|
||||
for (i, (seg, piece)) in pieces.iter().enumerate() {
|
||||
let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() else { continue };
|
||||
match i.checked_sub(1).and_then(|j| pieces.get(j)) {
|
||||
None => out.move_to(p),
|
||||
Some((prev, _)) => join(out, *prev, *seg, p, d),
|
||||
}
|
||||
for e in piece.elements().iter().skip(1) {
|
||||
out.push(*e);
|
||||
}
|
||||
}
|
||||
if closed && let (Some((last, _)), Some((first, piece))) = (pieces.last(), pieces.first()) {
|
||||
if let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() {
|
||||
// Lines meeting at the start: both ends move to their miter point.
|
||||
match miter(*last, *first, d) {
|
||||
Some(m) if is_line(*last) && is_line(*first) => {
|
||||
set_last(out, m);
|
||||
if let Some(PathEl::MoveTo(_)) = out.elements().get(begin) {
|
||||
out.elements_mut()[begin] = PathEl::MoveTo(m);
|
||||
}
|
||||
}
|
||||
_ => out.line_to(p),
|
||||
}
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
segs.clear();
|
||||
};
|
||||
let mut start = Point::ZERO;
|
||||
let mut cur = Point::ZERO;
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
flush(&mut segs, closed, &mut out);
|
||||
closed = false;
|
||||
start = p;
|
||||
cur = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
segs.push(PathSeg::Line(kurbo::Line::new(cur, p)));
|
||||
cur = p;
|
||||
}
|
||||
PathEl::QuadTo(a, b) => {
|
||||
segs.push(PathSeg::Quad(kurbo::QuadBez::new(cur, a, b)));
|
||||
cur = b;
|
||||
}
|
||||
PathEl::CurveTo(a, b, c) => {
|
||||
segs.push(PathSeg::Cubic(CubicBez::new(cur, a, b, c)));
|
||||
cur = c;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if (cur - start).hypot() > 1e-9 {
|
||||
segs.push(PathSeg::Line(kurbo::Line::new(cur, start)));
|
||||
}
|
||||
closed = true;
|
||||
flush(&mut segs, closed, &mut out);
|
||||
closed = false;
|
||||
cur = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut segs, closed, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn offset_seg(s: PathSeg, d: f64, tolerance: f64) -> Option<BezPath> {
|
||||
// Far-off or non-finite geometry (hostile files) is left out rather than offset.
|
||||
let sane = |p: Point| p.is_finite() && p.x.abs() < 1e7 && p.y.abs() < 1e7;
|
||||
let ok = match s {
|
||||
PathSeg::Line(l) => sane(l.p0) && sane(l.p1),
|
||||
PathSeg::Quad(q) => sane(q.p0) && sane(q.p1) && sane(q.p2),
|
||||
PathSeg::Cubic(c) => sane(c.p0) && sane(c.p1) && sane(c.p2) && sane(c.p3),
|
||||
};
|
||||
if !ok || !d.is_finite() {
|
||||
return None;
|
||||
}
|
||||
match s {
|
||||
PathSeg::Line(l) => {
|
||||
let n = right(l.p1 - l.p0)?;
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(l.p0 + n * d);
|
||||
p.line_to(l.p1 + n * d);
|
||||
Some(p)
|
||||
}
|
||||
PathSeg::Quad(q) => offset_seg(PathSeg::Cubic(q.raise()), d, tolerance),
|
||||
PathSeg::Cubic(c) => {
|
||||
// Nearly straight cubics (collinear control points) are offset as lines.
|
||||
let chord = c.p3 - c.p0;
|
||||
let flat = |p: Point| chord.cross(p - c.p0).abs() <= 1e-9 * chord.hypot2().max(1e-18);
|
||||
if flat(c.p1) && flat(c.p2) {
|
||||
return offset_seg(PathSeg::Line(kurbo::Line::new(c.p0, c.p3)), d, tolerance);
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
kurbo::offset::offset_cubic(c, d, tolerance, &mut p);
|
||||
Some(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The segment's tangent at its start and at its end (the first non-zero control vector).
|
||||
fn tangents(s: PathSeg) -> (Vec2, Vec2) {
|
||||
let first = |v: &[Vec2]| v.iter().copied().find(|v| v.hypot2() > 1e-18).unwrap_or(Vec2::ZERO);
|
||||
match s {
|
||||
PathSeg::Line(l) => (l.p1 - l.p0, l.p1 - l.p0),
|
||||
PathSeg::Quad(q) => (first(&[q.p1 - q.p0, q.p2 - q.p0]), first(&[q.p2 - q.p1, q.p2 - q.p0])),
|
||||
PathSeg::Cubic(c) => (first(&[c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0]), first(&[c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0])),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_line(s: PathSeg) -> bool {
|
||||
matches!(s, PathSeg::Line(_))
|
||||
}
|
||||
|
||||
/// Move the path's last point to `p`.
|
||||
fn set_last(out: &mut BezPath, p: Point) {
|
||||
if let Some(e) = out.elements_mut().last_mut() {
|
||||
match e {
|
||||
PathEl::MoveTo(q) | PathEl::LineTo(q) => *q = p,
|
||||
PathEl::QuadTo(_, q) => *q = p,
|
||||
PathEl::CurveTo(_, _, q) => *q = p,
|
||||
PathEl::ClosePath => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the offsets of `prev` and `next` meet at their shared corner (their miter point), or
|
||||
/// None for a corner sharper than about 11 degrees or a degenerate tangent.
|
||||
fn miter(prev: PathSeg, next: PathSeg, d: f64) -> Option<Point> {
|
||||
let (_, t0) = tangents(prev);
|
||||
let (t1, _) = tangents(next);
|
||||
let (n0, n1) = (right(t0)?, right(t1)?);
|
||||
let denom = 1.0 + n0.dot(n1);
|
||||
(denom > 0.02).then(|| prev.end() + (n0 + n1) * (d / denom)).filter(|m| m.is_finite())
|
||||
}
|
||||
|
||||
/// Join the offset of `prev` (ending at `out`'s current point) to the offset of `next`, which
|
||||
/// starts at `p`: two lines meet at their miter point (both ends moved there, so an inner corner
|
||||
/// doesn't loop); anything else is bridged straight.
|
||||
fn join(out: &mut BezPath, prev: PathSeg, next: PathSeg, p: Point, d: f64) {
|
||||
match miter(prev, next, d) {
|
||||
Some(m) if is_line(prev) && is_line(next) => set_last(out, m),
|
||||
Some(m) => {
|
||||
out.line_to(m);
|
||||
out.line_to(p);
|
||||
}
|
||||
None => out.line_to(p),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
|
||||
#[test]
|
||||
fn bands_split_the_width_like_powerpoint() {
|
||||
for c in [Compound::Single, Compound::Double, Compound::ThinThick, Compound::ThickThin, Compound::Triple] {
|
||||
let b = bands(c);
|
||||
assert_eq!(b.first().map(|x| x.0), Some(0.0));
|
||||
assert_eq!(b.last().map(|x| x.1), Some(1.0));
|
||||
assert!(b.windows(2).all(|w| w[0].1 < w[1].0), "{c:?}: bands in order with gaps");
|
||||
}
|
||||
// Thin-thick: the thin line on the left, thick-thin its mirror.
|
||||
let (tt, th) = (bands(Compound::ThinThick), bands(Compound::ThickThin));
|
||||
assert!((tt[0].1 - tt[0].0) < (tt[1].1 - tt[1].0));
|
||||
assert!((th[0].1 - th[0].0) > (th[1].1 - th[1].0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsetting_a_rectangle_grows_or_shrinks_it() {
|
||||
// Clockwise in y-down coordinates: the left of travel is the outside.
|
||||
let r = kurbo::Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
|
||||
let out = offset(&r, -5.0, 0.01).bounding_box();
|
||||
assert!(
|
||||
(out.x0 + 5.0).abs() < 1e-6 && (out.y0 + 5.0).abs() < 1e-6 && (out.x1 - 105.0).abs() < 1e-6 && (out.y1 - 55.0).abs() < 1e-6,
|
||||
"{out:?}"
|
||||
);
|
||||
let inn = offset(&r, 5.0, 0.01).bounding_box();
|
||||
assert!((inn.x0 - 5.0).abs() < 1e-6 && (inn.y1 - 45.0).abs() < 1e-6, "{inn:?}");
|
||||
// Corners meet at their miter points: the area is the offset rectangle's.
|
||||
assert!((offset(&r, -5.0, 0.01).area().abs() - 110.0 * 60.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsetting_a_circle_changes_its_radius() {
|
||||
let c = kurbo::Circle::new((50.0, 50.0), 20.0).to_path(0.01);
|
||||
let b = offset(&c, 4.0, 0.01).bounding_box();
|
||||
let w = b.width() / 2.0;
|
||||
assert!((w - 16.0).abs() < 0.2 || (w - 24.0).abs() < 0.2, "radius {w}");
|
||||
// A hostile path never panics and yields finite output.
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((0.0, 0.0));
|
||||
p.curve_to((f64::MAX, 0.0), (0.0, f64::MIN_POSITIVE), (1.0, 1.0));
|
||||
p.close_path();
|
||||
let _ = offset(&p, 3.0, 0.01);
|
||||
}
|
||||
}
|
||||
+195
-15
@@ -21,6 +21,41 @@ struct Cache {
|
||||
bytes: usize,
|
||||
clock: u64,
|
||||
bad: HashMap<usize, Arc<Vec<u8>>>,
|
||||
/// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them.
|
||||
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>)>,
|
||||
}
|
||||
|
||||
/// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas
|
||||
/// decoded at all; and the decoded size of all cached GIFs together. Hostile GIFs (millions of
|
||||
/// tiny frames, a huge canvas) stop at these instead of exhausting memory.
|
||||
const GIF_SIDE: u32 = 1024;
|
||||
const GIF_BUDGET: usize = 64 << 20;
|
||||
const GIF_MAX_FRAMES: usize = 1000;
|
||||
const GIF_MAX_CANVAS: u64 = 4096 * 4096;
|
||||
const GIF_CACHE_BUDGET: usize = 192 << 20;
|
||||
|
||||
/// The frames of an animated GIF and when each one ends, in seconds from the start of a loop.
|
||||
struct Anim {
|
||||
frames: Vec<Arc<Pixmap>>,
|
||||
ends: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Anim {
|
||||
/// Decoded bytes held by the frames.
|
||||
fn bytes(&self) -> usize {
|
||||
self.frames.iter().map(|f| f.width() as usize * f.height() as usize * 4).sum()
|
||||
}
|
||||
|
||||
/// The frame showing `t` seconds into the (looping) animation and the seconds until the next.
|
||||
fn at(&self, t: f64) -> (usize, f64) {
|
||||
let total = self.ends.last().copied().unwrap_or(0.0);
|
||||
if total <= 0.0 || !t.is_finite() {
|
||||
return (0, 1.0);
|
||||
}
|
||||
let local = t.max(0.0) % total;
|
||||
let i = self.ends.partition_point(|e| *e <= local).min(self.ends.len().saturating_sub(1));
|
||||
(i, (self.ends.get(i).copied().unwrap_or(total) - local).max(0.001))
|
||||
}
|
||||
}
|
||||
|
||||
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
|
||||
@@ -44,14 +79,40 @@ fn adjust_key(a: &PictureAdjust) -> u64 {
|
||||
if let Some(c) = a.clear_color {
|
||||
mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40);
|
||||
}
|
||||
if let Some((d, l)) = duotone_rgb(a) {
|
||||
for c in [d, l] {
|
||||
mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA.
|
||||
/// The duotone's dark and light colours. The painter resolves them to plain RGB first (see
|
||||
/// `paint::resolved`); a colour that is still a scheme reference falls back to black and white.
|
||||
fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_color::Rgba)> {
|
||||
use deckcraft_model::style::ColorBase;
|
||||
let (d, l) = a.duotone.as_ref()?;
|
||||
let rgb = |c: &deckcraft_model::style::ColorRef, or: deckcraft_color::Rgba| match &c.base {
|
||||
ColorBase::Rgb { rgb } if c.mods.is_empty() => *rgb,
|
||||
_ => or,
|
||||
};
|
||||
Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE)))
|
||||
}
|
||||
|
||||
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF; WMF and EMF drawn at
|
||||
/// [`crate::metafile::DEFAULT_SIDE`]) to straight RGBA.
|
||||
pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
decode_at(bytes, crate::metafile::DEFAULT_SIDE)
|
||||
}
|
||||
|
||||
/// [`decode`], with a metafile drawn with its longer side `side` px.
|
||||
fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
if bytes.len() > 512 << 20 {
|
||||
return None;
|
||||
}
|
||||
if crate::metafile::is_metafile(bytes) {
|
||||
return crate::metafile::rasterize(bytes, side);
|
||||
}
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 || (w as u64) * (h as u64) > 400_000_000 {
|
||||
@@ -79,13 +140,13 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
|
||||
}) {
|
||||
return Some(pm);
|
||||
}
|
||||
let Some(mut img) = decode(bytes) else {
|
||||
let target = 1u32 << bucket;
|
||||
let Some(mut img) = decode_at(bytes, target) else {
|
||||
with(|c| {
|
||||
c.bad.insert(addr, bytes.clone());
|
||||
});
|
||||
return None;
|
||||
};
|
||||
let target = 1u32 << bucket;
|
||||
let (w, h) = img.dimensions();
|
||||
if w.max(h) > target.max(16) && w.max(h) > 4096.min(target * 2) {
|
||||
let k = target as f64 / w.max(h) as f64;
|
||||
@@ -94,18 +155,7 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
|
||||
}
|
||||
apply_adjust(&mut img, adj);
|
||||
let (w, h) = img.dimensions();
|
||||
if w > u16::MAX as u32 || h > u16::MAX as u32 {
|
||||
return None;
|
||||
}
|
||||
let data: Vec<vello_cpu::color::PremulRgba8> = img
|
||||
.pixels()
|
||||
.map(|p| {
|
||||
let a = p[3] as u16;
|
||||
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
|
||||
})
|
||||
.collect();
|
||||
let pm = Arc::new(Pixmap::from_parts(data, w as u16, h as u16));
|
||||
let pm = pixmap(&img)?;
|
||||
with(|c| {
|
||||
c.clock += 1;
|
||||
let n = w as usize * h as usize * 4;
|
||||
@@ -121,11 +171,95 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
|
||||
Some(pm)
|
||||
}
|
||||
|
||||
/// Straight RGBA to a premultiplied pixmap.
|
||||
fn pixmap(img: &image::RgbaImage) -> Option<Arc<Pixmap>> {
|
||||
let (w, h) = img.dimensions();
|
||||
if w > u16::MAX as u32 || h > u16::MAX as u32 {
|
||||
return None;
|
||||
}
|
||||
let data: Vec<vello_cpu::color::PremulRgba8> = img
|
||||
.pixels()
|
||||
.map(|p| {
|
||||
let a = p[3] as u16;
|
||||
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
|
||||
})
|
||||
.collect();
|
||||
Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16)))
|
||||
}
|
||||
|
||||
/// Decode every frame of an animated GIF (`None` for a still or broken one).
|
||||
fn decode_anim(bytes: &[u8], adj: &PictureAdjust) -> Option<Anim> {
|
||||
use image::{AnimationDecoder, ImageDecoder};
|
||||
let mut dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
dec.set_limits(image::Limits::default()).ok()?;
|
||||
let (w, h) = dec.dimensions();
|
||||
if w == 0 || h == 0 || (w as u64) * (h as u64) > GIF_MAX_CANVAS {
|
||||
return None;
|
||||
}
|
||||
let k = (GIF_SIDE as f64 / w.max(h) as f64).min(1.0);
|
||||
let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1));
|
||||
let size = nw as usize * nh as usize * 4;
|
||||
let (mut frames, mut ends, mut t) = (Vec::new(), Vec::new(), 0.0);
|
||||
for f in dec.into_frames() {
|
||||
let Ok(f) = f else { break };
|
||||
if frames.len() >= GIF_MAX_FRAMES || (frames.len() + 1) * size > GIF_BUDGET {
|
||||
log::info!("animated GIF: keeping its first {} frames", frames.len());
|
||||
break;
|
||||
}
|
||||
let (n, d) = f.delay().numer_denom_ms();
|
||||
let ms = if d == 0 { 0.0 } else { n as f64 / d as f64 };
|
||||
// Like browsers: frames of 10 ms or less show for 100 ms.
|
||||
t += if ms <= 10.0 { 0.1 } else { ms / 1000.0 };
|
||||
let mut img = f.into_buffer();
|
||||
if img.dimensions() != (nw, nh) {
|
||||
img = image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle);
|
||||
}
|
||||
apply_adjust(&mut img, adj);
|
||||
frames.push(pixmap(&img)?);
|
||||
ends.push(t);
|
||||
}
|
||||
(frames.len() > 1).then_some(Anim { frames, ends })
|
||||
}
|
||||
|
||||
/// The decoded animation of GIF bytes, once per bytes and adjustments.
|
||||
fn anim(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust) -> Option<Arc<Anim>> {
|
||||
if !bytes.starts_with(b"GIF8") {
|
||||
return None;
|
||||
}
|
||||
let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj));
|
||||
if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) {
|
||||
return a;
|
||||
}
|
||||
let a = decode_anim(bytes, adj).map(Arc::new);
|
||||
with(|c| {
|
||||
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
|
||||
if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 {
|
||||
c.anims.clear();
|
||||
}
|
||||
c.anims.insert(key, (bytes.clone(), a.clone()));
|
||||
});
|
||||
a
|
||||
}
|
||||
|
||||
/// For an animated GIF: the frame showing `t` seconds into its (looping) play and the seconds until
|
||||
/// the next frame. `None` for anything else.
|
||||
pub fn gif_frame(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust, t: f64) -> Option<(usize, f64)> {
|
||||
anim(bytes, adj).map(|a| a.at(t))
|
||||
}
|
||||
|
||||
/// The frame of an animated GIF at `t` seconds.
|
||||
pub(crate) fn gif_pixmap(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust, t: f64) -> Option<Arc<Pixmap>> {
|
||||
let a = anim(bytes, adj)?;
|
||||
a.frames.get(a.at(t).0).cloned()
|
||||
}
|
||||
|
||||
fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
let sat = a.saturation.unwrap_or(1.0);
|
||||
if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() {
|
||||
return;
|
||||
}
|
||||
let duo = duotone_rgb(a);
|
||||
let b = a.brightness.clamp(-1.0, 1.0) * 255.0;
|
||||
let c = a.contrast.clamp(-1.0, 1.0);
|
||||
let cf = if c >= 0.0 { 1.0 / (1.0 - c * 0.99) } else { 1.0 + c };
|
||||
@@ -145,6 +279,12 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
*v = y + (*v - y) * s;
|
||||
*v = (*v - 128.0) * cf + 128.0 + b;
|
||||
}
|
||||
// Duotone: the pixel's luminance picks a colour between the dark and the light one.
|
||||
if let Some((d, l)) = duo {
|
||||
let t = ((0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255.0).clamp(0.0, 1.0);
|
||||
rgb =
|
||||
[d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t];
|
||||
}
|
||||
for (i, v) in rgb.iter().enumerate() {
|
||||
if let Some(ch) = p.0.get_mut(i) {
|
||||
*ch = v.round().clamp(0.0, 255.0) as u8;
|
||||
@@ -152,3 +292,43 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duotone_maps_luminance_between_its_two_colours() {
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_model::style::ColorRef;
|
||||
let mut img = image::RgbaImage::from_fn(3, 1, |x, _| image::Rgba([[0, 0, 0, 255], [128, 128, 128, 200], [255, 255, 255, 255]][x as usize]));
|
||||
let adj =
|
||||
PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(100, 40, 0)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))), ..Default::default() };
|
||||
apply_adjust(&mut img, &adj);
|
||||
assert_eq!(img.get_pixel(0, 0).0, [100, 40, 0, 255], "black takes the dark colour");
|
||||
assert_eq!(img.get_pixel(2, 0).0, [250, 240, 200, 255], "white takes the light colour");
|
||||
let mid = img.get_pixel(1, 0).0;
|
||||
assert!((mid[0] as i32 - 175).abs() <= 1 && (mid[1] as i32 - 140).abs() <= 1 && (mid[2] as i32 - 100).abs() <= 1, "{mid:?}");
|
||||
assert_eq!(mid[3], 200, "alpha is kept");
|
||||
// Different duotones are cached separately.
|
||||
let other = PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(0, 0, 0)), ColorRef::rgb(Rgba::rgb(255, 0, 0)))), ..Default::default() };
|
||||
assert_ne!(adjust_key(&adj), adjust_key(&other));
|
||||
assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() {
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
let frames = (0..GIF_MAX_FRAMES + 200).map(|i| {
|
||||
let c = if i % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 255] };
|
||||
Frame::from_parts(RgbaImage::from_pixel(1, 1, Rgba(c)), 0, 0, Delay::from_numer_denom_ms(20, 1))
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
image::codecs::gif::GifEncoder::new(&mut out).encode_frames(frames).expect("gif");
|
||||
let a = decode_anim(&out, &PictureAdjust::default()).expect("animated");
|
||||
assert_eq!(a.frames.len(), GIF_MAX_FRAMES);
|
||||
assert_eq!(a.ends.len(), GIF_MAX_FRAMES);
|
||||
}
|
||||
}
|
||||
|
||||
+427
-86
@@ -8,10 +8,21 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod chart;
|
||||
mod colr;
|
||||
|
||||
mod compound;
|
||||
mod images;
|
||||
mod metafile;
|
||||
mod morph_path;
|
||||
mod morph_text;
|
||||
mod paint;
|
||||
mod pattern;
|
||||
|
||||
mod pathgrad;
|
||||
mod placed;
|
||||
mod ramp;
|
||||
mod table;
|
||||
mod warp;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -23,13 +34,17 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::style::{Effects, Fill, Line};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{Geom, PhType, Presentation, Shape, ShapeId, ShapeKind, Slide};
|
||||
use deckcraft_text::datetime::{self, DateTime};
|
||||
use deckcraft_text::{Fields, Opts};
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
|
||||
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||||
use vello_cpu::{RenderContext, Resources, peniko};
|
||||
|
||||
pub use images::decode as decode_image;
|
||||
pub use images::{decode as decode_image, gif_frame};
|
||||
pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
|
||||
pub use table::row_heights as table_row_heights;
|
||||
|
||||
/// Largest pixmap side we render (vello_cpu uses u16 sizes).
|
||||
pub const MAX_SIDE: u32 = 16_000;
|
||||
@@ -140,6 +155,8 @@ pub struct RenderOpts<'a> {
|
||||
pub skip: &'a [ShapeId],
|
||||
/// Hide the text of this shape (the UI draws it while editing).
|
||||
pub skip_text: Option<ShapeId>,
|
||||
/// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
|
||||
pub gif_times: &'a [(ShapeId, f64)],
|
||||
/// Worker threads (0 = this thread; required when effects use filters).
|
||||
pub threads: u16,
|
||||
/// Translate the slide within the output (pixels) and output size override.
|
||||
@@ -147,7 +164,10 @@ pub struct RenderOpts<'a> {
|
||||
pub size: Option<(u32, u32)>,
|
||||
/// Draw a background colour outside the slide (output larger than slide).
|
||||
pub clear: Option<Rgba>,
|
||||
/// Show this text in every date field instead of the date.
|
||||
pub date_text: Option<String>,
|
||||
/// The local date and time date fields show (`None`: the system clock).
|
||||
pub now: Option<DateTime>,
|
||||
}
|
||||
|
||||
impl Default for RenderOpts<'_> {
|
||||
@@ -159,28 +179,45 @@ impl Default for RenderOpts<'_> {
|
||||
state: None,
|
||||
skip: &[],
|
||||
skip_text: None,
|
||||
gif_times: &[],
|
||||
threads: 0,
|
||||
offset: (0.0, 0.0),
|
||||
size: None,
|
||||
clear: None,
|
||||
date_text: None,
|
||||
now: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SlideFields {
|
||||
num: u32,
|
||||
date: String,
|
||||
/// Text for every date field instead of the date ([`RenderOpts::date_text`]).
|
||||
date: Option<String>,
|
||||
footer: String,
|
||||
/// The local date and time date fields show; `None`: they keep their saved text.
|
||||
now: Option<DateTime>,
|
||||
}
|
||||
|
||||
impl SlideFields {
|
||||
fn new(num: u32, footer: String, opts: &RenderOpts) -> Self {
|
||||
SlideFields { num, date: opts.date_text.clone(), footer, now: opts.now.or_else(DateTime::now_local) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Fields for SlideFields {
|
||||
fn field(&self, kind: &str) -> Option<String> {
|
||||
self.field_in(kind, None)
|
||||
}
|
||||
|
||||
fn field_in(&self, kind: &str, lang: Option<&str>) -> Option<String> {
|
||||
if kind == "slidenum" {
|
||||
return Some(self.num.to_string());
|
||||
}
|
||||
if kind.starts_with("datetime") {
|
||||
return Some(self.date.clone());
|
||||
if datetime::is_date_field(kind) {
|
||||
// An automatic date: today's, in the format of its type. (A fixed date is plain text
|
||||
// in the slide's placeholder, not a field.)
|
||||
return self.date.clone().or_else(|| datetime::field_text(kind, lang, self.now?));
|
||||
}
|
||||
if kind == "footer" {
|
||||
return Some(self.footer.clone());
|
||||
@@ -191,11 +228,16 @@ impl Fields for SlideFields {
|
||||
|
||||
pub struct Renderer {
|
||||
resources: Resources,
|
||||
/// Outlines morphing in the frame being drawn by `blend`.
|
||||
paths: Vec<PathMorph>,
|
||||
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
||||
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
||||
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new() }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +255,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
||||
return;
|
||||
}
|
||||
let (e, _) = resolve::effects(&rctx, s);
|
||||
if e.is_some_and(|e| !e.is_empty()) {
|
||||
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
|
||||
any = true;
|
||||
}
|
||||
});
|
||||
@@ -226,6 +268,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
||||
any
|
||||
}
|
||||
|
||||
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
|
||||
/// text styles it inherits)? Such text is blurred, which needs filters.
|
||||
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
|
||||
use deckcraft_model::text::{ListStyle, RunProps};
|
||||
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
|
||||
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
|
||||
let Some(t) = &s.text else { return false };
|
||||
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|
||||
|| styled(&t.list_style)
|
||||
|| styled(&rctx.master.title_style)
|
||||
|| styled(&rctx.master.body_style)
|
||||
|| styled(&rctx.master.other_style)
|
||||
}
|
||||
|
||||
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
||||
/// blank image (logged), never a crash.
|
||||
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
||||
@@ -241,6 +297,36 @@ pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Ima
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the
|
||||
/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by
|
||||
/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:
|
||||
/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or
|
||||
/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw
|
||||
/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies
|
||||
/// to `shapes` only.
|
||||
pub fn render_blend(
|
||||
pres: &Presentation,
|
||||
old: usize,
|
||||
new: usize,
|
||||
mix: f64,
|
||||
shapes: &[(Shape, bool)],
|
||||
text: &[TextMorph],
|
||||
paths: &[PathMorph],
|
||||
opts: &RenderOpts,
|
||||
) -> Image {
|
||||
let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts))
|
||||
})) {
|
||||
Ok(img) => img,
|
||||
Err(_) => {
|
||||
log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide");
|
||||
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
||||
render_slide(pres, new, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a layout (Slide Master view thumbnail and canvas).
|
||||
pub fn render_layout(pres: &Presentation, master: usize, layout: Option<usize>, opts: &RenderOpts) -> Image {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) {
|
||||
@@ -371,46 +457,52 @@ struct Frame<'a> {
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
|
||||
fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) {
|
||||
let (w, h) = output_size(pres, opts);
|
||||
let mut ctx = RenderContext::new_with(
|
||||
w,
|
||||
h,
|
||||
vello_cpu::RenderSettings {
|
||||
num_threads: if cfg!(target_arch = "wasm32") || needs_filters(pres, slide) { 0 } else { opts.threads },
|
||||
..Default::default()
|
||||
},
|
||||
vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") || filters { 0 } else { opts.threads }, ..Default::default() },
|
||||
);
|
||||
if let Some(c) = opts.clear {
|
||||
ctx.set_paint(color(c, 1.0));
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
}
|
||||
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
|
||||
let fields = SlideFields {
|
||||
num: pres.slide_number(index),
|
||||
date: opts.date_text.clone().unwrap_or_else(|| pres.header_footer.date_text.clone()),
|
||||
footer: pres.header_footer.footer_text.clone(),
|
||||
};
|
||||
let f = Frame { pres, opts, fields };
|
||||
(ctx, w, h, view)
|
||||
}
|
||||
|
||||
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
|
||||
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
|
||||
}
|
||||
|
||||
/// Background plus the master and layout graphics the slide shows.
|
||||
fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) {
|
||||
self.background(ctx, f, rctx, Some(slide), view);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(f.pres, rctx.master);
|
||||
for s in &rctx.master.shapes {
|
||||
if s.ph.is_none() {
|
||||
self.shape(ctx, f, &mctx, s, view, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if show_layout && let Some(l) = rctx.layout {
|
||||
let lctx = Ctx::for_layout(f.pres, rctx.master, l);
|
||||
for s in &l.shapes {
|
||||
if s.ph.is_none() {
|
||||
self.shape(ctx, f, &lctx, s, view, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
|
||||
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
|
||||
if let Some(rctx) = Ctx::for_slide(pres, slide) {
|
||||
self.background(&mut ctx, &f, &rctx, Some(slide), view);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
for s in &rctx.master.shapes {
|
||||
if s.ph.is_none() {
|
||||
self.shape(&mut ctx, &f, &mctx, s, view, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if show_layout && let Some(l) = rctx.layout {
|
||||
let lctx = Ctx::for_layout(pres, rctx.master, l);
|
||||
for s in &l.shapes {
|
||||
if s.ph.is_none() {
|
||||
self.shape(&mut ctx, &f, &lctx, s, view, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.backdrop(&mut ctx, &f, &rctx, slide, view);
|
||||
for s in &slide.shapes {
|
||||
self.shape(&mut ctx, &f, &rctx, s, view, 0);
|
||||
}
|
||||
@@ -418,11 +510,69 @@ impl Renderer {
|
||||
self.finish(ctx, w, h)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend(
|
||||
&mut self,
|
||||
pres: &Presentation,
|
||||
old: (&Slide, usize),
|
||||
new: (&Slide, usize),
|
||||
mix: f64,
|
||||
shapes: &[(Shape, bool)],
|
||||
text: &[TextMorph],
|
||||
paths: &[PathMorph],
|
||||
opts: &RenderOpts,
|
||||
) -> Image {
|
||||
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
|
||||
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
|
||||
// Backdrop graphics never take shape states: their ids may clash with the frame's shapes.
|
||||
let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts };
|
||||
if mix < 1.0 {
|
||||
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) };
|
||||
self.backdrop(&mut ctx, &f, &co, old.0, view);
|
||||
}
|
||||
if mix > 0.0 {
|
||||
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) };
|
||||
let layered = mix < 1.0;
|
||||
if layered {
|
||||
ctx.push_layer(None, None, Some(mix as f32), None, None);
|
||||
}
|
||||
self.backdrop(&mut ctx, &f, &cn, new.0, view);
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) };
|
||||
let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) };
|
||||
let mut drawn: Vec<ShapeId> = Vec::new();
|
||||
// Set only now: the backdrop's ids may clash with the frame's.
|
||||
self.paths = paths.to_vec();
|
||||
for (s, from_old) in shapes {
|
||||
let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) };
|
||||
let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else {
|
||||
self.shape(&mut ctx, f, c, s, view, 0);
|
||||
continue;
|
||||
};
|
||||
self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0);
|
||||
drawn.push(s.id);
|
||||
if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) {
|
||||
continue;
|
||||
}
|
||||
let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id);
|
||||
if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) {
|
||||
let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b));
|
||||
morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view);
|
||||
}
|
||||
}
|
||||
self.paths.clear();
|
||||
self.finish(ctx, w, h)
|
||||
}
|
||||
|
||||
fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
|
||||
let (w, h) = output_size(pres, opts);
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
|
||||
let f = Frame { pres, opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } };
|
||||
let f = Frame { pres, opts, fields: SlideFields::new(1, String::new(), opts) };
|
||||
if let Some(m) = pres.masters.get(mi) {
|
||||
match li.and_then(|i| m.layouts.get(i)) {
|
||||
Some(l) => {
|
||||
@@ -464,7 +614,12 @@ impl Renderer {
|
||||
let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
ctx.set_transform(view);
|
||||
let path = r.to_path(0.1);
|
||||
// PowerPoint lays every background over white: a picture stretched into part of the
|
||||
// slide, or a see-through colour, leaves white around and under it.
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.fill_path(&path);
|
||||
paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres);
|
||||
self.bg = Some((fill, ph, view));
|
||||
}
|
||||
|
||||
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
@@ -529,7 +684,11 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) {
|
||||
let geo = shape_geometry(s, w, h);
|
||||
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
|
||||
Some(pm) => morph_path::geometry(pm, s, w, h),
|
||||
None => (shape_geometry(s, w, h), Vec::new()),
|
||||
};
|
||||
let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 });
|
||||
let (fill, fill_ph) = resolve::fill(rctx, s);
|
||||
let (line, line_ph) = resolve::line(rctx, s);
|
||||
let (effects, fx_ph) = resolve::effects(rctx, s);
|
||||
@@ -554,15 +713,23 @@ impl Renderer {
|
||||
ctx.set_transform(m);
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
ctx.push_clip_layer(&outline);
|
||||
paint::picture(ctx, f.pres, pf, bounds, m);
|
||||
let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1);
|
||||
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
|
||||
// preview picture, stretched over the frame, as PowerPoint draws it.
|
||||
ShapeKind::Opaque { preview: Some(media), .. } => {
|
||||
ctx.set_transform(m);
|
||||
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
|
||||
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
|
||||
}
|
||||
ShapeKind::Media(mc) => {
|
||||
ctx.set_transform(m);
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
if let Some(p) = mc.poster {
|
||||
let pf = deckcraft_model::style::PictureFill { media: p, ..Default::default() };
|
||||
paint::picture(ctx, f.pres, &pf, bounds, m);
|
||||
paint::picture(ctx, f.pres, &pf, bounds, m, None);
|
||||
} else if mc.video {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255));
|
||||
ctx.fill_rect(&bounds);
|
||||
@@ -575,13 +742,22 @@ impl Renderer {
|
||||
if let Some(fl) = &fill
|
||||
&& !(empty_ph && f.opts.edit && s.fill.is_none())
|
||||
{
|
||||
for sp in &geo.paths {
|
||||
if sp.fill == FillMode::None {
|
||||
for (i, sp) in geo.paths.iter().enumerate() {
|
||||
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, 1.0, f.pres);
|
||||
match (fl, &self.bg) {
|
||||
// `useBgFill`: the slide's background, laid out on the slide (not the
|
||||
// shape), shows through the shape.
|
||||
(Fill::Background, Some((bg, ph, view))) => {
|
||||
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
let path = (view.inverse() * m) * sp.path.clone();
|
||||
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
|
||||
}
|
||||
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
}
|
||||
@@ -590,26 +766,31 @@ impl Renderer {
|
||||
// Outline.
|
||||
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||||
for sp in geo.paths.iter().filter(|p| p.stroke) {
|
||||
stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw);
|
||||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) {
|
||||
stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw, fade(i).stroke);
|
||||
}
|
||||
if s.is_line() || geo.is_open() {
|
||||
arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw);
|
||||
}
|
||||
}
|
||||
if let Some(r) = soft {
|
||||
// Feather: fade the shape's alpha towards the outline.
|
||||
// Feather: the shape (fill and line) fades to nothing at its outline. Measured on
|
||||
// PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of
|
||||
// r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself.
|
||||
ctx.set_transform(m);
|
||||
ctx.push_layer(
|
||||
None,
|
||||
Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)),
|
||||
None,
|
||||
None,
|
||||
Some(blur(r * 0.5 * scale_of(m))),
|
||||
Some(blur(r / 3.0 * scale_of(m))),
|
||||
);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
|
||||
let inset = Rect::new(0.0, 0.0, w, h).inset(-r * 0.6);
|
||||
ctx.fill_rect(&inset);
|
||||
ctx.fill_path(&outline);
|
||||
ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut));
|
||||
ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&outline);
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
}
|
||||
@@ -621,11 +802,14 @@ impl Renderer {
|
||||
ctx.push_clip_layer(&outline);
|
||||
let c = rctx.color(&sh.color, fx_ph);
|
||||
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
||||
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
|
||||
// As PowerPoint draws it (measured on its export): the shadow falls inside along the
|
||||
// edges it points to (45 degrees: the bottom and right), blurred by a third of the
|
||||
// blur radius.
|
||||
ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m))));
|
||||
ctx.set_paint(color(c, 1.0));
|
||||
let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1);
|
||||
let mut hole = outline.clone();
|
||||
hole.apply_affine(Affine::translate(d));
|
||||
hole.apply_affine(Affine::translate(-d));
|
||||
ring.extend(hole.iter());
|
||||
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
||||
ctx.fill_path(&ring);
|
||||
@@ -641,7 +825,10 @@ impl Renderer {
|
||||
let tr = geo.text_rect;
|
||||
if !body.is_empty() {
|
||||
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
draw_layout_with(ctx, &l, tr, m, &st.paras);
|
||||
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
|
||||
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
|
||||
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
|
||||
}
|
||||
} else if f.opts.edit && s.ph.is_some() {
|
||||
let kind = s.ph_type().unwrap_or(PhType::Body);
|
||||
let prompt_body = prompt_body(rctx, s, kind);
|
||||
@@ -696,7 +883,8 @@ impl Renderer {
|
||||
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
|
||||
let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local };
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
|
||||
// Blurred by a third of the blur radius, as PowerPoint's export shows.
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||||
ctx.set_transform(sm);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
@@ -705,10 +893,12 @@ impl Renderer {
|
||||
if let Some(g) = &e.glow {
|
||||
let c = rctx.color(&g.color, ph);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
|
||||
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
|
||||
// out from the outline and gone by r.
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k)));
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
|
||||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&silhouette);
|
||||
ctx.fill_path(&silhouette);
|
||||
ctx.pop_layer();
|
||||
@@ -746,7 +936,7 @@ fn blur(sigma: f64) -> Filter {
|
||||
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None })
|
||||
}
|
||||
|
||||
fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, path: &BezPath, m: Affine, lw: f64) {
|
||||
fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, path: &BezPath, m: Affine, lw: f64, alpha: f64) {
|
||||
use deckcraft_model::style::{LineCap, LineJoin};
|
||||
let cap = match line.cap.unwrap_or_default() {
|
||||
LineCap::Flat => kurbo::Cap::Butt,
|
||||
@@ -770,13 +960,26 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
|
||||
ctx.set_transform(m);
|
||||
match &line.fill {
|
||||
Some(Fill::Solid { color: c }) => {
|
||||
ctx.set_paint(color(rctx.color(c, ph), 1.0));
|
||||
ctx.set_paint(color(rctx.color(c, ph), alpha));
|
||||
}
|
||||
Some(Fill::Gradient(g)) => {
|
||||
let c = g.stops.first().map(|s| rctx.color(&s.color, ph)).unwrap_or(Rgba::BLACK);
|
||||
ctx.set_paint(color(c, 1.0));
|
||||
ctx.set_paint(color(c, alpha));
|
||||
}
|
||||
_ => ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)),
|
||||
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
|
||||
}
|
||||
// Compound lines: each band stroked along the path moved to its place across the width.
|
||||
let compound = line.compound.unwrap_or_default();
|
||||
if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 {
|
||||
let tolerance = 0.1 / scale_of(m).max(1e-6);
|
||||
for &(a, b) in compound::bands(compound) {
|
||||
let d = (-0.5 + (a + b) / 2.0) * lw;
|
||||
let mut band = sk.clone();
|
||||
band.width = (b - a) * lw;
|
||||
ctx.set_stroke(band);
|
||||
ctx.stroke_path(&compound::offset(path, d, tolerance));
|
||||
}
|
||||
return;
|
||||
}
|
||||
ctx.set_stroke(sk);
|
||||
ctx.stroke_path(path);
|
||||
@@ -936,6 +1139,32 @@ pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody
|
||||
draw_layout(ctx, &l, tr, m);
|
||||
}
|
||||
|
||||
/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local →
|
||||
/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre.
|
||||
///
|
||||
/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's
|
||||
/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about
|
||||
/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180°
|
||||
/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this.
|
||||
pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine {
|
||||
let c = tr.center().to_vec2();
|
||||
let mut t = m;
|
||||
if m.determinant() < 0.0 && c.is_finite() {
|
||||
t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c);
|
||||
}
|
||||
if rotation != 0.0 && rotation.is_finite() && c.is_finite() {
|
||||
t = t * Affine::translate(c) * Affine::rotate(rotation.to_radians()) * Affine::translate(-c);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws.
|
||||
pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option<deckcraft_geom::warp::Warp> {
|
||||
let bp = resolve::body(rctx, s);
|
||||
let name = bp.warp.as_deref()?;
|
||||
deckcraft_geom::warp::warp(name, &bp.warp_adj, tr)
|
||||
}
|
||||
|
||||
/// Draw an already laid-out text block.
|
||||
pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) {
|
||||
draw_layout_with(ctx, l, tr, m, &[]);
|
||||
@@ -944,12 +1173,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr:
|
||||
/// Draw a laid-out text block with per-paragraph animation states.
|
||||
pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) {
|
||||
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
|
||||
let m = if l.rotation != 0.0 {
|
||||
let c = tr.center().to_vec2();
|
||||
m * Affine::translate(c) * Affine::rotate(l.rotation.to_radians()) * Affine::translate(-c)
|
||||
} else {
|
||||
m
|
||||
};
|
||||
let m = text_transform(m, tr, l.rotation);
|
||||
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
|
||||
let ps = para(d.para);
|
||||
if !ps.visible || ps.opacity <= 0.001 {
|
||||
@@ -962,6 +1186,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
for d in l.decos.iter().filter(|d| d.behind) {
|
||||
deco(ctx, d);
|
||||
}
|
||||
// Text shadows, then glows, under all the text.
|
||||
for pass in [TextEffect::Shadow, TextEffect::Glow] {
|
||||
for run in &l.runs {
|
||||
let ps = para(run.para);
|
||||
if ps.visible && ps.opacity > 0.001 {
|
||||
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
for run in &l.runs {
|
||||
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
continue;
|
||||
@@ -971,30 +1204,24 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
continue;
|
||||
}
|
||||
let m = m * Affine::translate(ps.offset);
|
||||
let alpha = run.alpha * ps.opacity;
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
ctx.set_paint(color(run.color, alpha));
|
||||
// A gradient fill starts over on every line and spans that line's text (from its top to
|
||||
// its bottom, from its first to its last character), as in PowerPoint.
|
||||
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let path = glyph(&run.face, *gid);
|
||||
if path.elements().is_empty() {
|
||||
let at = m * Affine::translate((*x, *y));
|
||||
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
|
||||
draw_glyph(ctx, run, *gid, at, ps.opacity);
|
||||
continue;
|
||||
};
|
||||
let li = line_at(l, run.para, *y);
|
||||
if !grads.iter().any(|(i, _)| *i == li) {
|
||||
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
|
||||
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
|
||||
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
|
||||
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
|
||||
}
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let gm = m * Affine::translate((*x, *y)) * skew * Affine::scale(k);
|
||||
ctx.set_transform(gm);
|
||||
if alpha > 0.0 && run.color.a > 0 {
|
||||
ctx.fill_path(&path);
|
||||
if run.fake_bold {
|
||||
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
if let Some((oc, ow)) = run.outline {
|
||||
ctx.set_paint(color(oc, ps.opacity));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
ctx.set_paint(color(run.color, alpha));
|
||||
}
|
||||
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
|
||||
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
|
||||
}
|
||||
}
|
||||
for d in l.decos.iter().filter(|d| !d.behind) {
|
||||
@@ -1002,6 +1229,120 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
|
||||
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
|
||||
l.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, li)| li.para == para)
|
||||
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The box a line's text covers: its top and bottom, its first and last caret.
|
||||
fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
|
||||
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
|
||||
let r = Rect::new(x0, li.top, x1, li.bottom);
|
||||
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum TextEffect {
|
||||
Shadow,
|
||||
Glow,
|
||||
}
|
||||
|
||||
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
|
||||
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
|
||||
let Some(fx) = run.effects else { return };
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let px = scale_of(m);
|
||||
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
|
||||
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
|
||||
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
|
||||
// past the glyph's edge and fades out by about two thirds of r.
|
||||
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
|
||||
_ => return,
|
||||
};
|
||||
let alpha = (c.a as f64 / 255.0) * opacity;
|
||||
if alpha <= 0.0 {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let path = glyph(&run.face, *gid);
|
||||
if path.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
|
||||
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
|
||||
ctx.set_transform(at);
|
||||
ctx.fill_path(&path);
|
||||
if grow > 0.0 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
||||
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
|
||||
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
|
||||
draw_glyph_with(ctx, run, gid, at, opacity, None);
|
||||
}
|
||||
|
||||
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
|
||||
/// the glyph's origin to that space) instead of the run's colour.
|
||||
fn draw_glyph_with(
|
||||
ctx: &mut RenderContext,
|
||||
run: &deckcraft_text::GlyphRun,
|
||||
gid: u32,
|
||||
at: Affine,
|
||||
opacity: f64,
|
||||
fill: Option<(&peniko::Gradient, Affine)>,
|
||||
) {
|
||||
let alpha = run.alpha * opacity;
|
||||
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
return;
|
||||
}
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
// Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
|
||||
// strokes the plain glyph below.
|
||||
if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) {
|
||||
return;
|
||||
}
|
||||
let path = glyph(&run.face, gid);
|
||||
if path.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
ctx.set_transform(at * skew * Affine::scale(k));
|
||||
if let Some((g, to_text)) = fill
|
||||
&& alpha > 0.0
|
||||
{
|
||||
ctx.set_paint(g.clone());
|
||||
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
|
||||
ctx.fill_path(&path);
|
||||
ctx.reset_paint_transform();
|
||||
} else if alpha > 0.0 && run.color.a > 0 {
|
||||
ctx.set_paint(color(run.color, alpha));
|
||||
ctx.fill_path(&path);
|
||||
if run.fake_bold {
|
||||
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
if let Some((oc, ow)) = run.outline {
|
||||
ctx.set_paint(color(oc, opacity));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
|
||||
pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
|
||||
let rctx = Ctx::for_slide(pres, slide)?;
|
||||
@@ -1012,7 +1353,7 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64)
|
||||
let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let opts = RenderOpts { scale, ..Default::default() };
|
||||
let f = Frame { pres, opts: &opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } };
|
||||
let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) };
|
||||
let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2());
|
||||
RENDERER.with(|r| {
|
||||
let mut r = r.borrow_mut();
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by
|
||||
//! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go
|
||||
//! through the same crop, tile and recolour path as any other picture.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_metafile::PlacedItem;
|
||||
use kurbo::{Affine, Rect, Shape as _};
|
||||
use vello_cpu::{Pixmap, RenderContext, Resources, peniko};
|
||||
|
||||
/// Longest side of a metafile drawn without a target size (export, thumbnails of the picture).
|
||||
pub const DEFAULT_SIDE: u32 = 1024;
|
||||
/// Largest side drawn.
|
||||
const MAX_SIDE: u32 = 8192;
|
||||
|
||||
/// Is `bytes` a WMF or EMF file?
|
||||
pub fn is_metafile(bytes: &[u8]) -> bool {
|
||||
deckcraft_metafile::is_metafile(bytes)
|
||||
}
|
||||
|
||||
/// Draw the metafile `bytes` into straight RGBA with its longer side `side` px (transparent where
|
||||
/// it draws nothing). `None` when it doesn't parse or has no size.
|
||||
pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
let mut pic = deckcraft_metafile::parse(bytes).ok()?;
|
||||
if !pic.has_size() {
|
||||
return None;
|
||||
}
|
||||
let side = side.clamp(16, MAX_SIDE) as f64;
|
||||
let (pw, ph) = (pic.width as f64, pic.height as f64);
|
||||
let k = side / pw.max(ph);
|
||||
let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let pixmaps: Vec<Option<Arc<Pixmap>>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect();
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let r = Rect::new(0.0, 0.0, w as f64, h as f64);
|
||||
ctx.push_clip_layer(&r.to_path(0.1));
|
||||
for it in pic.place(r, [0.0; 4]) {
|
||||
match it {
|
||||
PlacedItem::Path { path, fill, stroke, even_odd } => {
|
||||
if let Some(f) = fill {
|
||||
ctx.set_paint(rgba(f));
|
||||
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
ctx.fill_path(&path);
|
||||
}
|
||||
// GDI pens: round caps and joins; a cosmetic (zero-width) pen is one pixel.
|
||||
if let Some((s, sw)) = stroke {
|
||||
ctx.set_paint(rgba(s));
|
||||
ctx.set_stroke(kurbo::Stroke::new(sw.max(1.0)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
PlacedItem::Bitmap { index, rect } => {
|
||||
let Some(Some(pm)) = pixmaps.get(index) else { continue };
|
||||
let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
||||
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
|
||||
});
|
||||
ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh));
|
||||
ctx.fill_rect(&rect);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.pop_layer();
|
||||
ctx.flush();
|
||||
let mut px = vec![0u8; w as usize * h as usize * 4];
|
||||
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut px) {
|
||||
ctx.render(pm, &mut Resources::new());
|
||||
}
|
||||
// Premultiplied to straight.
|
||||
for p in px.as_chunks_mut::<4>().0 {
|
||||
let a = p[3] as u32;
|
||||
if a != 0 && a != 255 {
|
||||
for c in p.iter_mut().take(3) {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
image::RgbaImage::from_raw(w as u32, h as u32, px)
|
||||
}
|
||||
|
||||
fn rgba(c: [u8; 4]) -> peniko::Color {
|
||||
peniko::Color::from_rgba8(c[0], c[1], c[2], c[3])
|
||||
}
|
||||
|
||||
/// Straight RGBA rows to a premultiplied pixmap.
|
||||
fn premul(w: u32, h: u32, px: &[u8]) -> Option<Arc<Pixmap>> {
|
||||
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 {
|
||||
return None;
|
||||
}
|
||||
let data = px
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.take(w as usize * h as usize)
|
||||
.map(|p| {
|
||||
let a = p[3] as u16;
|
||||
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
|
||||
})
|
||||
.collect();
|
||||
Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16)))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Morph of an outline: a frame shape whose geometry turns from the old shape's into its own (a
|
||||
//! circle into a star), with the points aligned by `deckcraft_geom::morph`.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_geom::morph::{Fade, GeometryMorph};
|
||||
use deckcraft_geom::preset::Geometry;
|
||||
use deckcraft_model::{Geom, Shape, ShapeId};
|
||||
use kurbo::Vec2;
|
||||
|
||||
use crate::shape_geometry;
|
||||
|
||||
/// A frame shape (see [`crate::render_blend`]) whose outline morphs from `from` into its own
|
||||
/// geometry.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PathMorph {
|
||||
pub id: ShapeId,
|
||||
/// The old shape's geometry.
|
||||
pub from: Geom,
|
||||
/// The boxes of the old and the new shape on their own slides.
|
||||
pub from_box: Xfrm,
|
||||
pub to_box: Xfrm,
|
||||
/// Progress, 0..1 (already eased).
|
||||
pub t: f64,
|
||||
}
|
||||
|
||||
/// `now / own`, or 1 when that is no usable scale (a line's zero height).
|
||||
fn ratio(now: f64, own: f64) -> f64 {
|
||||
let r = now / own;
|
||||
if own.abs() > 1e-9 && r.is_finite() { r } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at their
|
||||
/// own sizes (so the alignment does not change while the box grows), then scaled into the box.
|
||||
/// With a [`Fade`] per sub-path.
|
||||
pub(crate) fn geometry(pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec<Fade>) {
|
||||
let a = shape_geometry(&Shape { geom: pm.from.clone(), ..Shape::default() }, pm.from_box.w, pm.from_box.h);
|
||||
let b = shape_geometry(s, pm.to_box.w, pm.to_box.h);
|
||||
let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h));
|
||||
GeometryMorph::new(&a, &b).at(pm.t, scale(pm.from_box), scale(pm.to_box))
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Morph by words or characters: the words (or characters) two texts share travel from their place
|
||||
//! in the old text to their place in the new one, growing or shrinking to the new size; the rest
|
||||
//! of the old text fades out and the rest of the new one fades in, both riding the moving box.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use deckcraft_text::{Fields, GlyphRun, Opts, TextLayout};
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
use vello_cpu::RenderContext;
|
||||
|
||||
use crate::{color, draw_glyph, draw_layout, shape_geometry, text_transform};
|
||||
|
||||
/// The texts of two frame shapes (see [`crate::render_blend`]) morphed by words or characters.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TextMorph {
|
||||
/// The frame shape from the old slide and the one from the new slide.
|
||||
pub old: ShapeId,
|
||||
pub new: ShapeId,
|
||||
/// Their boxes on their own slides: where the shared words start and end.
|
||||
pub from: Xfrm,
|
||||
pub to: Xfrm,
|
||||
/// Characters instead of words.
|
||||
pub chars: bool,
|
||||
/// Progress, 0..1 (already eased).
|
||||
pub t: f64,
|
||||
}
|
||||
|
||||
/// A word or character: its text, its glyphs (run, glyph) and the origin of its first glyph.
|
||||
struct Token {
|
||||
key: String,
|
||||
glyphs: Vec<(usize, usize)>,
|
||||
origin: Point,
|
||||
run: usize,
|
||||
}
|
||||
|
||||
/// Words (or characters) of a layout in reading order, plus glyphs that belong to none (bullets).
|
||||
fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
|
||||
let mut cells: Vec<(usize, usize, char, usize, usize)> = Vec::new();
|
||||
let mut loose = Vec::new();
|
||||
for (ri, run) in l.runs.iter().enumerate() {
|
||||
for gi in 0..run.glyphs.len() {
|
||||
match run.cells.get(gi) {
|
||||
Some((ci, ch)) => cells.push((run.para, *ci, *ch, ri, gi)),
|
||||
None => loose.push((ri, gi)),
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.sort_by_key(|c| (c.0, c.1, c.3, c.4));
|
||||
let mut out: Vec<Token> = Vec::new();
|
||||
let mut last: Option<(usize, usize)> = None;
|
||||
for (para, ci, ch, ri, gi) in cells {
|
||||
if ch.is_whitespace() {
|
||||
last = None;
|
||||
continue;
|
||||
}
|
||||
let same_char = last == Some((para, ci));
|
||||
let next_char = last.is_some_and(|(p, c)| p == para && c.checked_add(1) == Some(ci));
|
||||
let extend = same_char || (!chars && next_char);
|
||||
match out.last_mut() {
|
||||
Some(tok) if extend => {
|
||||
if !same_char {
|
||||
tok.key.push(ch);
|
||||
}
|
||||
tok.glyphs.push((ri, gi));
|
||||
}
|
||||
_ => {
|
||||
let origin = l.runs.get(ri).and_then(|r| r.glyphs.get(gi)).map(|g| Point::new(g.1, g.2)).unwrap_or(Point::ZERO);
|
||||
out.push(Token { key: ch.to_string(), glyphs: vec![(ri, gi)], origin, run: ri });
|
||||
}
|
||||
}
|
||||
last = Some((para, ci));
|
||||
}
|
||||
(out, loose)
|
||||
}
|
||||
|
||||
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
|
||||
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
|
||||
let mut used = vec![false; a.len()];
|
||||
b.iter()
|
||||
.map(|tb| {
|
||||
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?;
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
Some(i)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
|
||||
let body = s.text.as_ref().filter(|b| !b.is_empty())?;
|
||||
let rect = shape_geometry(s, x.w, x.h).text_rect;
|
||||
Some(deckcraft_text::layout(ctx, s, body, &Opts { rect, fields, prompt_color: None, no_shrink: false }))
|
||||
}
|
||||
|
||||
fn glyph_at(l: &TextLayout, (ri, gi): (usize, usize)) -> Option<(&GlyphRun, u32, Point)> {
|
||||
let run = l.runs.get(ri)?;
|
||||
let g = run.glyphs.get(gi)?;
|
||||
Some((run, g.0, Point::new(g.1, g.2)))
|
||||
}
|
||||
|
||||
fn same_look(a: &GlyphRun, b: &GlyphRun) -> bool {
|
||||
a.face.id() == b.face.id() && a.size == b.size && a.color == b.color && a.alpha == b.alpha && a.outline == b.outline
|
||||
}
|
||||
|
||||
fn lerp(a: f64, b: f64, t: f64) -> f64 {
|
||||
a + (b - a) * t
|
||||
}
|
||||
|
||||
/// Draw the morphing text of `a` (old slide, `ca`) and `b` (new slide, `cb`); `now` is the box both
|
||||
/// frame shapes share at this moment, `view` the slide-to-pixels transform.
|
||||
pub(crate) fn draw(
|
||||
ctx: &mut RenderContext,
|
||||
(ca, a, fa): (&Ctx, &Shape, &dyn Fields),
|
||||
(cb, b, fb): (&Ctx, &Shape, &dyn Fields),
|
||||
m: &TextMorph,
|
||||
now: Xfrm,
|
||||
view: Affine,
|
||||
) {
|
||||
let t = if m.t.is_finite() { m.t.clamp(0.0, 1.0) } else { 1.0 };
|
||||
let la = layout(ca, a, m.from, fa);
|
||||
let lb = layout(cb, b, m.to, fb);
|
||||
let mt = view * now.affine();
|
||||
// Rotated (vertical) text: no per-word travel, the two texts cross-fade on the moving box.
|
||||
let rotated = la.as_ref().is_some_and(|l| l.rotation != 0.0) || lb.as_ref().is_some_and(|l| l.rotation != 0.0);
|
||||
if rotated || la.is_none() || lb.is_none() {
|
||||
for (l, s, alpha, x) in [(la, a, 1.0 - t, m.from), (lb, b, t, m.to)] {
|
||||
if let Some(l) = l
|
||||
&& alpha > 0.001
|
||||
{
|
||||
ctx.push_layer(None, None, Some(alpha as f32), None, None);
|
||||
draw_layout(ctx, &l, shape_geometry(s, x.w, x.h).text_rect, mt);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (Some(la), Some(lb)) = (la, lb) else { return };
|
||||
let (ta, loose_a) = tokens(&la, m.chars);
|
||||
let (tb, loose_b) = tokens(&lb, m.chars);
|
||||
let pairs = matches(&ta, &tb);
|
||||
// Flipped boxes keep their text readable, as when drawn (see `text_transform`).
|
||||
let (tra, trb) = (shape_geometry(a, m.from.w, m.from.h).text_rect, shape_geometry(b, m.to.w, m.to.h).text_rect);
|
||||
let (ma, mb) = (text_transform(view * m.from.affine(), tra, 0.0), text_transform(view * m.to.affine(), trb, 0.0));
|
||||
let (mta, mtb) = (text_transform(mt, tra, 0.0), text_transform(mt, trb, 0.0));
|
||||
let c = mta.as_coeffs();
|
||||
let linear = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
|
||||
let mut moved_a = vec![false; ta.len()];
|
||||
// Shared tokens travel.
|
||||
for (j, i) in pairs.iter().enumerate() {
|
||||
let (Some(i), Some(tok_b)) = (*i, tb.get(j)) else { continue };
|
||||
let Some(tok_a) = ta.get(i) else { continue };
|
||||
if let Some(f) = moved_a.get_mut(i) {
|
||||
*f = true;
|
||||
}
|
||||
let (Some(ra), Some(rb)) = (la.runs.get(tok_a.run), lb.runs.get(tok_b.run)) else { continue };
|
||||
let p = (ma * tok_a.origin).lerp(mb * tok_b.origin, t);
|
||||
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
|
||||
let same = same_look(ra, rb);
|
||||
let alpha_a = if same { 1.0 } else { 1.0 - t };
|
||||
let sa = lerp(1.0, ratio, t);
|
||||
for &g in &tok_a.glyphs {
|
||||
if let Some((run, gid, pos)) = glyph_at(&la, g) {
|
||||
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sa) * Affine::translate(pos - tok_a.origin);
|
||||
draw_glyph(ctx, run, gid, at, alpha_a);
|
||||
}
|
||||
}
|
||||
if !same {
|
||||
let sb = lerp(1.0 / ratio, 1.0, t);
|
||||
for &g in &tok_b.glyphs {
|
||||
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
|
||||
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sb) * Affine::translate(pos - tok_b.origin);
|
||||
draw_glyph(ctx, run, gid, at, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The rest fades while following the shared tokens: they define a scale and shift from the old
|
||||
// text to the new one (F(p) = s·p + c, in pixels), so a line grows, shrinks and rearranges as
|
||||
// one. With nothing shared it rides the moving box.
|
||||
let shared: Vec<(Point, Point, f64)> = pairs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(j, i)| {
|
||||
let (tok_a, tok_b) = (ta.get((*i)?)?, tb.get(j)?);
|
||||
let (ra, rb) = (la.runs.get(tok_a.run)?, lb.runs.get(tok_b.run)?);
|
||||
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
|
||||
Some((ma * tok_a.origin, mb * tok_b.origin, ratio))
|
||||
})
|
||||
.collect();
|
||||
let n = shared.len() as f64;
|
||||
let s = shared.iter().map(|x| x.2).sum::<f64>() / n;
|
||||
let c = shared.iter().fold(Vec2::ZERO, |acc, (pa, pb, _)| acc + (pb.to_vec2() - pa.to_vec2() * s)) / n;
|
||||
let (place_a, place_b) = if !shared.is_empty() && s.is_finite() && s > 0.0 && c.is_finite() {
|
||||
// Old text: p ↦ lerp(p, F(p), t). New text: q ↦ lerp(F⁻¹(q), q, t).
|
||||
let a = Affine::translate(c * t) * Affine::scale(lerp(1.0, s, t));
|
||||
let b = Affine::translate(-c * ((1.0 - t) / s)) * Affine::scale((1.0 - t) / s + t);
|
||||
(a * ma, b * mb)
|
||||
} else {
|
||||
(mta, mtb)
|
||||
};
|
||||
let unmatched_a =
|
||||
ta.iter().enumerate().filter(|(i, _)| !moved_a.get(*i).copied().unwrap_or(false)).flat_map(|(_, tok)| tok.glyphs.iter().copied());
|
||||
for g in unmatched_a.chain(loose_a) {
|
||||
if let Some((run, gid, pos)) = glyph_at(&la, g) {
|
||||
draw_glyph(ctx, run, gid, place_a * Affine::translate(pos.to_vec2()), 1.0 - t);
|
||||
}
|
||||
}
|
||||
let unmatched_b =
|
||||
tb.iter().enumerate().filter(|(j, _)| pairs.get(*j).is_none_or(|p| p.is_none())).flat_map(|(_, tok)| tok.glyphs.iter().copied());
|
||||
for g in unmatched_b.chain(loose_b) {
|
||||
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
|
||||
draw_glyph(ctx, run, gid, place_b * Affine::translate(pos.to_vec2()), t);
|
||||
}
|
||||
}
|
||||
// Underlines, strikethroughs and highlights fade with their text.
|
||||
for (l, alpha, place) in [(&la, 1.0 - t, place_a), (&lb, t, place_b)] {
|
||||
for d in &l.decos {
|
||||
ctx.set_transform(place);
|
||||
ctx.set_paint(color(d.color, alpha));
|
||||
ctx.fill_rect(&d.rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
|
||||
tokens(l, chars).0.into_iter().map(|t| t.key).collect()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user