mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e019690354 | ||
|
|
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 | ||
|
|
d0e57d7e25 | ||
|
|
3e9bd4398a | ||
|
|
db67e0109f | ||
|
|
1839f41fce | ||
|
|
286ff74c8b | ||
|
|
e659ebcd27 | ||
|
|
a4bb8fe763 | ||
|
|
9da767d5f7 | ||
|
|
efb03b90a8 |
@@ -23,12 +23,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-15]
|
||||
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
||||
# release.yml builds macOS (a failing release is fixed and rebuilt).
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
@@ -39,11 +41,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: ci-${{ matrix.os }}
|
||||
- name: cargo xtask ci
|
||||
|
||||
@@ -23,34 +23,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
@@ -19,39 +19,5 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/deckcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/deckcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.deckcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.deckcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.deckcraft.yml')); assert m['id'] == 'ai.storyteller.deckcraft' and m['command'] == 'deckcraft'"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
+23
-414
@@ -1,14 +1,3 @@
|
||||
# Release pipeline: every push to `release` builds installers for every platform and creates or
|
||||
# updates the draft GitHub Release "DeckCraft v<version>" (tag v<version>). Recipe:
|
||||
# craftrules release/playbook.md.
|
||||
#
|
||||
# The version has one source: `[workspace.package] version` in Cargo.toml (`cargo xtask version`,
|
||||
# `cargo xtask version set X.Y.Z`). The app shows it in About and `--version`; the release name
|
||||
# and tag use it too. workflow_dispatch can override it for a test build (applied to the build).
|
||||
#
|
||||
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
|
||||
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
|
||||
# warning, never a failed build.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -22,413 +11,33 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
DECKCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# Optional font input (storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
# This commit adds fonts/latin-manifest.txt (the Latin presentation fonts).
|
||||
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
# Only the release branch builds (a manual dispatch from any other ref skips everything). The
|
||||
# branch itself is writable only by release-managers and org admins (ruleset "release").
|
||||
if: github.ref == 'refs/heads/release'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/common.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### DeckCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
environment: release
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10).
|
||||
runs-on: ${{ matrix.runner }}
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/deckcraft$|metainfo|applications'
|
||||
ldd target/release/deckcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak-sources:
|
||||
name: Flatpak crate sources
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Vendor the crate sources (flatpak builds run offline)
|
||||
run: |
|
||||
python3 -m venv "$RUNNER_TEMP/venv"
|
||||
"$RUNNER_TEMP/venv/bin/pip" install --quiet aiohttp tomlkit
|
||||
curl -fsSL -o "$RUNNER_TEMP/flatpak-cargo-generator.py" \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
"$RUNNER_TEMP/venv/bin/python" "$RUNNER_TEMP/flatpak-cargo-generator.py" Cargo.lock -o cargo-sources.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flatpak-cargo-sources
|
||||
path: cargo-sources.json
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Linux Flatpak
|
||||
needs: [version, flatpak-sources]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# Best effort: Flathub builds from source itself (playbook §7); the bundle is a convenience.
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
||||
options: --privileged
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: |
|
||||
# No cargo in this container: the same edit `cargo xtask version set` makes.
|
||||
sed -i "0,/^version = \".*\"/s//version = \"$DECKCRAFT_VERSION\"/" Cargo.toml
|
||||
grep -m1 '^version' Cargo.toml
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: flatpak-cargo-sources
|
||||
path: packaging/linux/flatpak
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
with:
|
||||
manifest-path: packaging/linux/flatpak/ai.storyteller.deckcraft.yml
|
||||
bundle: deckcraft-${{ needs.version.outputs.version }}-linux-x86_64.flatpak
|
||||
upload-artifact: false
|
||||
cache-key: flatpak-builder-${{ github.sha }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak
|
||||
path: deckcraft-*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
VERSION_OVERRIDE: ${{ inputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
envs: DECKCRAFT_VERSION DECKCRAFT_BUILD_DATE DECKCRAFT_BUILD_SHA VERSION_OVERRIDE
|
||||
# Copies the workspace (with dist/release) back; the target dir lives outside it so the
|
||||
# copy stays small.
|
||||
copyback: true
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "*"
|
||||
export CARGO_TARGET_DIR="$HOME/target" CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
if [ -n "$VERSION_OVERRIDE" ]; then cargo xtask version set "$DECKCRAFT_VERSION"; fi
|
||||
bash packaging/freebsd/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
# The installers are required; the Flatpak bundle and FreeBSD tarball are attached when they
|
||||
# built.
|
||||
if: >-
|
||||
!cancelled() && needs.version.result == 'success' && needs.macos.result == 'success' &&
|
||||
needs.windows.result == 'success' && needs.linux.result == 'success' && needs.web.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="DeckCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-title: DeckCraft
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft
|
||||
PowerPoint parity — and superiority (speed, openness, agent control). It runs natively on macOS,
|
||||
Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions:
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PrintCraft, EffectCraft, DesignCraft
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
|
||||
(`../<app>`).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
|
||||
@@ -158,5 +158,36 @@ to `main`.
|
||||
## 7. Planning
|
||||
|
||||
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. `ROADMAP.md` (committed)
|
||||
tracks status, milestones, parity and estimates; update it whenever a milestone task lands.
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
|
||||
docs follow craftrules `standards/progress-docs.md`:
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `ROADMAP.md` | Stage, headline numbers, dimensions, features, languages, progress log |
|
||||
| `docs/target-app-parity.md` | The parity assessment against PowerPoint: percentages, hours, calibration |
|
||||
| `docs/gaps.md` | Every known shortfall, ranked: the work list. Pick from the top |
|
||||
| `docs/roadmap.md` | Current focus, the beta plan, milestones |
|
||||
| `docs/parity-checklist.md` | Feature rows scored D/P/M; `cargo xtask parity` regenerates its summary |
|
||||
| `docs/file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`, `animation-parity.md`, `slideshow-parity.md` | Per-dimension and per-feature detail |
|
||||
| `docs/architecture.md` | How DeckCraft is built today |
|
||||
|
||||
When a task lands, re-score its checklist row, delete or update its `gaps.md` entry, add a line to
|
||||
the `ROADMAP.md` progress log, and update each touched doc's status line and revision history.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
@@ -18,7 +18,10 @@ 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 |
|
||||
|
||||
@@ -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
+288
-27
@@ -644,6 +644,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -866,7 +877,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -885,7 +896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -894,7 +905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -903,14 +914,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -919,14 +930,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -948,7 +959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -960,7 +971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -971,7 +982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -979,7 +990,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -988,7 +999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -997,7 +1008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1005,11 +1016,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1019,7 +1030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1037,7 +1048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1048,15 +1059,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
@@ -1071,7 +1082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1087,7 +1098,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -1103,19 +1114,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
"unicode-bidi",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1132,11 +1145,12 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1145,7 +1159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1202,6 +1216,17 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
@@ -1335,6 +1360,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -1633,6 +1659,15 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -2047,6 +2082,134 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -2348,6 +2511,12 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
@@ -3172,6 +3341,15 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -4196,6 +4374,16 @@ dependencies = [
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
@@ -4383,6 +4571,24 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.27.0"
|
||||
@@ -4699,6 +4905,22 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webbrowser"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
|
||||
dependencies = [
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"url",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -5356,6 +5578,12 @@ dependencies = [
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -5427,7 +5655,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5602,6 +5830,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"
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -48,6 +48,7 @@ rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
unicode-segmentation = "1"
|
||||
unicode-linebreak = "0.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
|
||||
@@ -51,13 +51,14 @@
|
||||
<a href="#getting-started">Getting started</a> ·
|
||||
<a href="#agents-cli-and-mcp">Agents, CLI and MCP</a> ·
|
||||
<a href="#roadmap">Roadmap</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">The Crafting Apps</a> ·
|
||||
<a href="#license-and-credits">License</a>
|
||||
</p>
|
||||
|
||||
## What works today
|
||||
|
||||
DeckCraft is in early development, heading for its first alpha (see the [roadmap](ROADMAP.md)).
|
||||
DeckCraft is in alpha, heading for beta (see the [roadmap](ROADMAP.md)).
|
||||
Today it can:
|
||||
|
||||
- **Make decks**: slide masters and eleven layouts with placeholders that inherit position and
|
||||
@@ -102,10 +103,43 @@ cd deckcraft
|
||||
cargo run --release -p deckcraft-cli -- render --sample --all --scale 1 out/
|
||||
```
|
||||
|
||||
On Linux, the build also needs the ALSA headers for slide-show sound: `alsa-lib-devel` on Fedora,
|
||||
`libasound2-dev` on Debian and Ubuntu, `alsa-lib` on Arch. Without them it stops at `alsa-sys`.
|
||||
|
||||
Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts): clone it next to this repo
|
||||
(`../craft-fonts`) and local builds pick it up automatically; without it DeckCraft uses your system
|
||||
fonts.
|
||||
|
||||
Each [GitHub release](https://github.com/storytold/deckcraft/releases) has ready-made builds, on Linux as
|
||||
an AppImage, a `.deb`, an `.rpm` and a tarball. On Gentoo, the community [::snakebyte
|
||||
overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as
|
||||
`app-office/deckcraft-bin` (not maintained by the DeckCraft team):
|
||||
|
||||
```sh
|
||||
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
|
||||
emaint sync -r snakebyte
|
||||
echo 'app-office/deckcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/deckcraft
|
||||
emerge --ask app-office/deckcraft-bin
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/deckcraft.log` in the
|
||||
settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraft/logs/`, or
|
||||
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
|
||||
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
|
||||
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
|
||||
PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
|
||||
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
|
||||
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DeckCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,deckcraft_pptx=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`deckcraft*=debug`). |
|
||||
|
||||
The logger is `apps/deckcraft/src/logging.rs`; the realtime audio callback does not log. The web
|
||||
app logs to the browser console instead.
|
||||
|
||||
## Agents, CLI and MCP
|
||||
|
||||
Every action in DeckCraft is a command with a stable id, so people, scripts and AI agents use the
|
||||
@@ -120,10 +154,72 @@ deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --con
|
||||
|
||||
## Roadmap
|
||||
|
||||
DeckCraft covers about **79% of PowerPoint's features by breadth** (93% of the core ones) and is
|
||||
roughly **80% of the way to a first alpha**. See [ROADMAP.md](ROADMAP.md) for the alpha checklist,
|
||||
parity estimates and what's next, and [docs/parity.md](docs/parity.md) for the feature-by-feature
|
||||
scorecard.
|
||||
DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core
|
||||
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
|
||||
[docs/gaps.md](docs/gaps.md) for every known shortfall and
|
||||
[docs/parity-checklist.md](docs/parity-checklist.md) for the feature-by-feature checklist.
|
||||
|
||||
## Downloads
|
||||
|
||||
**Download DeckCraft** from GitHub: the [latest release](https://github.com/storytold/deckcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/deckcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `deckcraft-<ver>-windows-x64.msi` | `deckcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `deckcraft-<ver>-windows-arm64.msi` | `deckcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `deckcraft-<ver>-windows-x86.msi` | `deckcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
|
||||
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
|
||||
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
|
||||
PowerShell, from the folder containing the executable:
|
||||
|
||||
```powershell
|
||||
$env:WGPU_BACKEND = "vulkan"
|
||||
& .\deckcraft.exe
|
||||
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
|
||||
```
|
||||
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `deckcraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `deckcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `deckcraft-<ver>-linux-x86_64.AppImage` | `deckcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `deckcraft-<ver>-linux-x86_64.flatpak` | `deckcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `deckcraft-<ver>-linux-x86_64.deb` | `deckcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `deckcraft-<ver>-linux-x86_64.rpm` | `deckcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `deckcraft-<ver>-linux-x86_64.tar.gz` | `deckcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
RISC-V 64 (riscv64): `deckcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (cross-compiled; needs glibc 2.39+, e.g. Ubuntu 24.04+).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `deckcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `deckcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
@@ -137,7 +233,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
|
||||
+128
-82
@@ -1,100 +1,146 @@
|
||||
# DeckCraft roadmap
|
||||
|
||||
DeckCraft aims at full PowerPoint parity — and to be better: faster, open (a documented zip+JSON
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
(macOS, Windows, Linux, BSD and the web).
|
||||
(macOS, Windows, Linux, BSD and the web). This page is the summary; the detail is in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) and the work list in
|
||||
[docs/gaps.md](docs/gaps.md).
|
||||
|
||||
## Status (2026-10-07)
|
||||
## Headline numbers
|
||||
|
||||
**Working today**
|
||||
- Document model: presentations, slides, masters and 11 standard layouts with placeholder inheritance
|
||||
(slide → layout → master → theme), sections, notes, comments, custom shows, header/footer fields,
|
||||
embedded media, unlimited undo built on shared slide snapshots.
|
||||
- Themes: 8 original themes with colour and font schemes, custom colours/fonts, backgrounds,
|
||||
slide size presets.
|
||||
- Shapes: ~150 preset geometries with adjustment handles, text boxes, pictures (with crop and
|
||||
adjustments), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more),
|
||||
basic SmartArt, groups, connectors, ink, action buttons, WordArt.
|
||||
- Text: in-place editing with caret, selection, keyboard and mouse; every Home-tab font and paragraph
|
||||
control; bullets and numbering; levels; autofit; columns; vertical text; fields; hyperlinks.
|
||||
- Editing: select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; z-order;
|
||||
group/ungroup; align/distribute; Format Painter; Selection pane; clipboard incl. images.
|
||||
- Transitions (fade, push, wipe, split, cover, uncover, zoom, morph…) and animations (entrance,
|
||||
emphasis, exit, motion paths, triggers, by-paragraph builds) on a shared timeline engine.
|
||||
- Slide show: full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen, presenter
|
||||
window, rehearse timings, custom shows, Set Up Show; Reading View.
|
||||
- Views: Normal (thumbnails with sections, slide, notes), Outline, Slide Sorter, Notes Page,
|
||||
Slide Master; zoom; grayscale.
|
||||
- Review: comments, accessibility checker, spelling.
|
||||
- Audio and video: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis and Opus audio; H.264,
|
||||
HEVC, VP9 and AV1 video in MP4/MOV/WebM/MKV (pure-Rust decoders); poster frames, trim, fades,
|
||||
volume, loop, rewind, play across slides, hide during show, full screen; an in-place control bar
|
||||
in the editor and `media.play/pause/stop/seek/info/posterFrame` for agents. WMA/WMV are
|
||||
recognised and embedded but not yet playable.
|
||||
- PDF export: slides, notes pages and handouts (1–9 per page) with a selectable real-text layer, hyperlinks and slide bookmarks (`file.export {format: "pdf", layout}`, File › Export…).
|
||||
- UI: PowerPoint-style ribbon with contextual tabs, ~240 original icons, status bar, panes, command
|
||||
palette, light/dark.
|
||||
- Automation: ~200 commands, every one reachable from `deckcraft-cli`, the app's JSON control
|
||||
channel and the MCP server (headless or connected to the running app).
|
||||
- Web: `apps/deckcraft-web` runs the same UI in the browser (trunk; WebGPU with WebGL2 fallback),
|
||||
opening the sample deck; Open/Insert use the browser file picker, Save/Export download.
|
||||
- Release CI: pushes to `release` build a draft GitHub Release (macOS universal dmg, Windows
|
||||
x64/x86 msi + zip, Linux AppImage/deb/rpm/tar.gz + Flatpak, FreeBSD tar.gz, web zip); signing
|
||||
secrets live in the `release` environment. Version: `cargo xtask version`.
|
||||
|
||||
**Next (in order):** first signed release run · PPTX corpus hardening against real-world decks ·
|
||||
Animation Pane and presenter view polish · vector PDF artwork · Format Shape pane depth (3-D,
|
||||
picture/texture options) · edit points · native macOS menu bar · print · Notes/Handout masters ·
|
||||
equations · SVG pictures · WMA/WMV decoding.
|
||||
|
||||
Milestone details live in `plan/execution-plan.md` (M0–M14, local planning notes).
|
||||
|
||||
## How close to an alpha (estimate, 2026-10-07)
|
||||
|
||||
An **alpha** here means: someone can install a signed build on macOS, Windows or Linux, make a real
|
||||
deck from scratch or from a PowerPoint file, present it with transitions, animations and media,
|
||||
save it back to `.pptx`/PDF without losing work, and hit no crashes on the common paths.
|
||||
|
||||
**DeckCraft is about 80% of the way to that alpha — roughly 30–40 wall-clock hours of a single
|
||||
Claude Opus 5.5 agent** (about 12–18 hours with three agents in parallel).
|
||||
|
||||
| Alpha blocker | State | Estimate |
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| First real release run: signing, notarization, installers verified on each OS | Workflows written and linted, never run | 6 h |
|
||||
| PPTX fidelity on a corpus of real decks (import, round-trip, opens without repair) | Verified on generated decks only | 8 h |
|
||||
| Presenter view and Animation Pane polish (timeline, reorder, preview) | Working, rough | 6 h |
|
||||
| Soak and fuzz the editor (random command sequences, big decks, undo/redo) for crashes and slowness | Unit tests and guards only | 5 h |
|
||||
| UI fidelity pass on the most-used ribbon groups and dialogs; first-run experience | Mostly there | 6 h |
|
||||
| Mascot app icon (owner), README/site screenshots, user docs | Placeholder icon | 3 h |
|
||||
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
|
||||
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
|
||||
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
| Essentials user | ~62% | estimated: core features × discounts for launch (×0.90), clarity (×0.95) and opening received files (×0.90), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
| Hours to beta | **~120–210** Opus 5.5 agent hours (~40–70 h with 3–4 agents) | estimated, [docs/roadmap.md](docs/roadmap.md) |
|
||||
| Hours to full parity | **~365–650** Opus 5.5 agent hours | estimated, [docs/target-app-parity.md](docs/target-app-parity.md) |
|
||||
|
||||
Already alpha-ready: editing, text, shapes (incl. connectors, freeform, merge), themes and
|
||||
backgrounds, tables, charts, transitions, animations, slide show, audio/video playback, PDF export,
|
||||
AutoRecover, CLI/MCP automation, web build.
|
||||
### Readiness by audience
|
||||
|
||||
## How far from full parity (estimate, 2026-10-07)
|
||||
| Audience | Ready | Opus 5.5 agent hours to ~95% | Work that dominates |
|
||||
|---|---:|---|---|
|
||||
| Full target (ready for real work) | ~55% | 340–600 (~60% parallelizes: ~120–210 h wall clock with 3–4 agents) | Feature depth across all 20 areas, legacy `.ppt`/`.odp` and video export, localization in 11+ languages, GPU raster and hardware decode, AI features |
|
||||
| Mainstream practitioner | ~53% | 150–250 (~50% parallelizes: ~70–120 h with 3 agents) | `.pptx` exchange (real-deck corpus, round-trip losses, EMF/SVG, embedded fonts) 45–75 h; depth in charts, animations, pictures, text, presenting, print/vector PDF 70–110 h; interaction (menu bar, Format Shape pane, font faces, dialogs) 25–40 h; stability 10–20 h |
|
||||
| Essentials user | ~62% | 50–90 (~40% parallelizes: ~30–55 h with 2–3 agents) | Opening received decks (corpus subset, EMF, SVG icons) 25–45 h; Windows launch fallback and Close prompt 6–12 h; font faces, Tab, paste, print, show navigation 12–20 h; start screen, menu bar, palette 6–10 h |
|
||||
|
||||
**Breadth: ~79% weighted** (P0 core 93%, P1 74%, P2 35%) over the 187 features of the PowerPoint catalogue, scored row by
|
||||
row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored
|
||||
done still lack some of PowerPoint's options, dialogs or pixel fidelity, so **overall parity
|
||||
including depth is about 62%**.
|
||||
Calibration is the same as for the full number (see [docs/target-app-parity.md](docs/target-app-parity.md#calibration-how-the-hours-were-derived)): a fix is 0.5–1 h, a checklist row to PowerPoint depth 2–4 h, a subsystem 6–35 h. The sets are nested, so essentials ≤ mainstream ≤ full. The full number is an additive weighted sum over the dimension table; only mainstream and essentials use multiplicative discounts.
|
||||
|
||||
**Remaining work to 100%: about 190 wall-clock hours of a single Claude Opus 5.5 agent** (±30%),
|
||||
or roughly 65–90 hours with four agents in parallel on separate crates:
|
||||
**Why alpha:** all six of PowerPoint's core workflows pass the alpha gate
|
||||
([docs/roadmap.md](docs/roadmap.md#alpha-gate-powerpoints-core-workflows)): build, open and save
|
||||
`.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with
|
||||
transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish
|
||||
have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77),
|
||||
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
|
||||
[docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Work | Estimate |
|
||||
|---|---|
|
||||
| Alpha blockers above | 35 h |
|
||||
| Open P0/P1 rows (vector PDF, print, edit points, SVG, multi-monitor, draw table, SmartArt text pane, Notes/Handout masters…) | 45 h |
|
||||
| Open P2 rows (equations, video export, 3-D, remove background, thesaurus, compare, record show…) | 40 h |
|
||||
| Depth and pixel fidelity of every ribbon group, dialog and pane against PowerPoint | 50 h |
|
||||
| Performance (incremental rendering, GPU raster, streaming media decode) | 20 h |
|
||||
## By dimension
|
||||
|
||||
Basis: in this session the engine, renderer, UI, show engine, PPTX, PDF, media and release
|
||||
pipeline were built in about 30 wall-clock hours with up to three agents in parallel; recent
|
||||
catalogue rows (connectors, freeform, merge shapes, AutoRecover, gradients) took 1–2 hours each.
|
||||
| Dimension | Ready | Hours to full | Doc |
|
||||
|---|---:|---|---|
|
||||
| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | ~8% | 55–90 + native review | [docs/localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance (unmeasured) | ~55% | 10–20 | [docs/hardware-parity.md](docs/hardware-parity.md#performance) |
|
||||
| Stability | ~60% | 10–20 | [docs/gaps.md](docs/gaps.md#stability) |
|
||||
| Platforms | ~80% | 5–10 | [Downloads in README](README.md#downloads) |
|
||||
| Ecosystem (templates; add-ins out of scope) | ~25% | 10–20 | [docs/gaps.md](docs/gaps.md#ecosystem) |
|
||||
| AI features | ~10% | 20–40 | [docs/gaps.md](docs/gaps.md#ai-features) |
|
||||
| Automation ✱ (CLI, control channel, MCP) | ahead | 0 | [docs/mcp.md](docs/mcp.md) |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Breadth (measured) | Ready (est.) | Hours | Doc |
|
||||
|---|---:|---:|---|---|
|
||||
| Application shell | 71% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Slides and sections | 90% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
|
||||
| Views | 90% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
|
||||
| Masters and themes | 85% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
|
||||
| Shapes | 86% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Format (fill, line, effects) | 81% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Arrange | 100% | ~90% | 1–2 | [ui-parity](docs/ui-parity.md) |
|
||||
| Text | 80% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
|
||||
| Tables | 89% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
|
||||
| Charts | 65% | ~45% | 12–20 | [gaps](docs/gaps.md#charts-data-grid-elements-and-the-newer-chart-types) |
|
||||
| SmartArt | 33% | ~25% | 10–16 | [gaps](docs/gaps.md#smartart-is-pre-drawn-shapes-only) |
|
||||
| Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
|
||||
| Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) |
|
||||
| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
|
||||
| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
|
||||
| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
|
||||
| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) |
|
||||
| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Files (commands; formats above) | 69% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
|
||||
|
||||
## Languages
|
||||
|
||||
English only today; no string catalog yet (Ukrainian is in review as PR #34). Detail:
|
||||
[docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | UI strings | Status | Hours to full |
|
||||
|---|---|---:|---|---|
|
||||
| English | en | 100% | full | 0 |
|
||||
| Simplified Chinese | zh-Hans | 0% | none | 6–10 |
|
||||
| Spanish | es | 0% | none | 3–5 |
|
||||
| Hindi | hi | 0% | none | 4–7 |
|
||||
| Arabic | ar | 0% (slide text: bidi and shaping done) | none | 10–16 |
|
||||
| French | fr | 0% | none | 3–5 |
|
||||
| Portuguese | pt | 0% | none | 3–5 |
|
||||
| Indonesian | id | 0% | none | 3–5 |
|
||||
| Japanese | ja | 0% | none | 4–7 |
|
||||
| German | de | 0% | none | 3–5 |
|
||||
| Korean | ko | 0% | none | 4–7 |
|
||||
| Vietnamese | vi | 0% | none | 3–5 |
|
||||
|
||||
Plus 8–14 h once for the catalog infrastructure. Other languages shipped: none. PowerPoint for Mac
|
||||
ships 26.
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
|
||||
image paste (#53), editor soak tests. 10–20 h.
|
||||
2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
|
||||
36–64 h.
|
||||
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
|
||||
4. **Output:** vector PDF, printing. 10–16 h.
|
||||
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
|
||||
presenter view on the right display, native macOS menu bar, Format Shape pane. 33–56 h.
|
||||
|
||||
## Agents: CLI and MCP
|
||||
|
||||
Every command is reachable from `deckcraft-cli` (`run`, `describe`, `commands`, `app` for the running
|
||||
window, `render`, `convert`), from MCP (`deckcraft-cli mcp`, optionally `--connect PORT`), and from the
|
||||
app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-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 |
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/deckcraft",
|
||||
"branch": "main",
|
||||
"head": "4b09f35c1d6fa50e2d94243a5d65d98fe49558e0",
|
||||
"head_date": "2026-10-09T14:48:26Z",
|
||||
"commits": 41,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 28,
|
||||
"lines_added": 124410,
|
||||
"lines_deleted": 3748,
|
||||
"binary_added": 47,
|
||||
"binary_deleted": 12,
|
||||
"first_commit": "2026-10-06T02:13:02Z",
|
||||
"last_commit": "2026-10-09T12:51:28Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 2,
|
||||
"lines_added": 56,
|
||||
"lines_deleted": 56,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:13:34Z",
|
||||
"last_commit": "2026-10-08T14:02:34Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 241,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:12:32Z",
|
||||
"last_commit": "2026-10-08T13:12:32Z"
|
||||
},
|
||||
{
|
||||
"login": "Pedroj-64",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 162,
|
||||
"lines_deleted": 42,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:31Z",
|
||||
"last_commit": "2026-10-09T14:28:31Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 706,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:35Z",
|
||||
"last_commit": "2026-10-09T14:29:47Z"
|
||||
},
|
||||
{
|
||||
"login": "alexanderalber",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 718,
|
||||
"lines_deleted": 53,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:40Z",
|
||||
"last_commit": "2026-10-09T14:32:41Z"
|
||||
},
|
||||
{
|
||||
"login": "wurstbrotdlx",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 65,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:45Z",
|
||||
"last_commit": "2026-10-09T14:28:45Z"
|
||||
},
|
||||
{
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 265,
|
||||
"lines_deleted": 77,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:28:49Z",
|
||||
"last_commit": "2026-10-09T14:40:13Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 67,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:30:12Z",
|
||||
"last_commit": "2026-10-09T14:30:12Z"
|
||||
},
|
||||
{
|
||||
"login": "hamzaelkachtaf4-star",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 1902,
|
||||
"lines_deleted": 282,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T14:48:26Z",
|
||||
"last_commit": "2026-10-09T14:48:26Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 33,
|
||||
"lines_added": 126890,
|
||||
"lines_deleted": 4162
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Fable",
|
||||
"version": "5.1",
|
||||
"commits": 1,
|
||||
"lines_added": 65,
|
||||
"lines_deleted": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
# display_name = "Nickname" # shown when the viewer picks "Display name"
|
||||
# sync_github_name = true # or: use the name on your public GitHub profile as display name
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.DimaSmaha]
|
||||
real_name = "Dmytro Smaha"
|
||||
display_name = "Dima Smaha"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Johannesgauss]
|
||||
sync_github_name = true
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -300,6 +300,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 +311,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 +335,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,180 @@ 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");
|
||||
}
|
||||
|
||||
#[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,183 @@
|
||||
//! 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)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if let Some(f) = chain.iter().find(|f| 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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,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.
|
||||
@@ -436,6 +440,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,
|
||||
@@ -467,6 +488,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,
|
||||
@@ -557,7 +579,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())
|
||||
}
|
||||
|
||||
@@ -744,7 +766,7 @@ impl FontDb {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_loaded(&self, family: &str) -> bool {
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
}
|
||||
|
||||
@@ -842,7 +864,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;
|
||||
}
|
||||
|
||||
+41
-83
@@ -1,21 +1,25 @@
|
||||
//! 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 fallback::{bundled_families, covers_cluster, ligature_carets};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_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 +57,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 +76,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 +123,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)
|
||||
@@ -196,6 +143,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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#![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 use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+35
-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 {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -266,6 +270,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -132,6 +132,8 @@ pub enum Align {
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
/// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
|
||||
JustLow,
|
||||
Distributed,
|
||||
}
|
||||
|
||||
@@ -142,6 +144,7 @@ impl Align {
|
||||
Align::Center => "ctr",
|
||||
Align::Right => "r",
|
||||
Align::Justify => "just",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "dist",
|
||||
}
|
||||
}
|
||||
@@ -150,7 +153,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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -387,6 +387,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 +420,91 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +609,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 +628,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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>) {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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,83 @@ 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)));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+183
-12
@@ -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,9 +79,26 @@ 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
|
||||
}
|
||||
|
||||
/// 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) to straight RGBA.
|
||||
pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
if bytes.len() > 512 << 20 {
|
||||
@@ -94,18 +146,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 +162,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 +270,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 +283,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);
|
||||
}
|
||||
}
|
||||
|
||||
+244
-77
@@ -9,8 +9,11 @@
|
||||
|
||||
mod chart;
|
||||
mod images;
|
||||
mod morph_path;
|
||||
mod morph_text;
|
||||
mod paint;
|
||||
mod placed;
|
||||
mod ramp;
|
||||
mod table;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -23,13 +26,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 +147,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 +156,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 +171,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 +220,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +275,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 +435,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 +488,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 +592,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 +662,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,7 +691,8 @@ 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();
|
||||
}
|
||||
ShapeKind::Media(mc) => {
|
||||
@@ -562,7 +700,7 @@ impl Renderer {
|
||||
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 +713,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,8 +737,8 @@ 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);
|
||||
@@ -746,7 +893,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 +917,13 @@ 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)),
|
||||
}
|
||||
ctx.set_stroke(sk);
|
||||
ctx.stroke_path(path);
|
||||
@@ -936,6 +1083,25 @@ 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
|
||||
}
|
||||
|
||||
/// 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 +1110,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 {
|
||||
@@ -971,30 +1132,8 @@ 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));
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let path = glyph(&run.face, *gid);
|
||||
if path.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
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));
|
||||
}
|
||||
draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity);
|
||||
}
|
||||
}
|
||||
for d in l.decos.iter().filter(|d| !d.behind) {
|
||||
@@ -1002,6 +1141,34 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let alpha = run.alpha * opacity;
|
||||
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
return;
|
||||
}
|
||||
let path = glyph(&run.face, gid);
|
||||
if path.elements().is_empty() {
|
||||
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 };
|
||||
ctx.set_transform(at * skew * Affine::scale(k));
|
||||
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 +1179,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,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()
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_geom::preset::FillMode;
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{Fill, GradientShape, PictureFill, PictureMode};
|
||||
use deckcraft_model::style::{ColorRef, Fill, GradientShape, PictureFill, PictureMode};
|
||||
use deckcraft_model::{PhType, Presentation};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
@@ -35,10 +35,11 @@ pub fn fill_path(
|
||||
if g.stops.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut stops: Vec<(f32, peniko::Color)> =
|
||||
g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0) as f32, color(shade(rctx.color(&s.color, ph), mode), alpha))).collect();
|
||||
let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect();
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let stops: Vec<peniko::ColorStop> = stops.into_iter().map(|(o, c)| peniko::ColorStop { offset: o, color: c.into() }).collect();
|
||||
// PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`.
|
||||
let stops: Vec<peniko::ColorStop> =
|
||||
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
|
||||
let grad = match &g.shape {
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
// Angle measured clockwise from left→right; span the bounds along that direction.
|
||||
@@ -65,7 +66,7 @@ pub fn fill_path(
|
||||
}
|
||||
Fill::Picture(pf) => {
|
||||
ctx.push_clip_layer(path);
|
||||
picture(ctx, pres, pf, bounds, m);
|
||||
picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
Fill::Pattern(p) => {
|
||||
@@ -120,15 +121,27 @@ fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
|
||||
}
|
||||
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
|
||||
|
||||
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes.
|
||||
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine) {
|
||||
/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the
|
||||
/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image
|
||||
/// cache can apply and key them without a colour scheme.
|
||||
pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::borrow::Cow<'a, PictureFill> {
|
||||
let Some((d, l)) = &pf.adjust.duotone else { return std::borrow::Cow::Borrowed(pf) };
|
||||
let mut out = pf.clone();
|
||||
out.adjust.duotone = Some((ColorRef::rgb(rctx.color(d, ph)), ColorRef::rgb(rctx.color(l, ph))));
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
|
||||
/// GIF shows its frame at `gif_time` seconds.
|
||||
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
|
||||
let Some(item) = pres.media(pf.media) else {
|
||||
missing(ctx, bounds);
|
||||
return;
|
||||
};
|
||||
let px_scale = crate::scale_of(m);
|
||||
let want = (bounds.width().max(bounds.height()) * px_scale).max(1.0);
|
||||
let Some(pm) = images::decode_for(&item.data, want, &pf.adjust) else {
|
||||
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
|
||||
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
|
||||
missing(ctx, bounds);
|
||||
return;
|
||||
};
|
||||
|
||||
+31
-23
@@ -7,12 +7,12 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
use deckcraft_text::{Opts, TextLayout};
|
||||
use kurbo::{Affine, Rect};
|
||||
|
||||
use crate::{SlideFields, shape_geometry};
|
||||
use crate::{SlideFields, shape_geometry, table};
|
||||
|
||||
/// One shape's text as drawn on the slide.
|
||||
pub struct PlacedText {
|
||||
pub layout: TextLayout,
|
||||
/// Shape-local → slide points (text rotation included).
|
||||
/// Shape-local → slide points (text rotation included, flips not: see [`crate::text_transform`]).
|
||||
pub transform: Affine,
|
||||
pub body: TextBody,
|
||||
}
|
||||
@@ -35,8 +35,7 @@ pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
|
||||
let mut out = Placed::default();
|
||||
let Some(slide) = pres.slides.get(index) else { return out };
|
||||
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
|
||||
let fields =
|
||||
SlideFields { num: pres.slide_number(index), date: pres.header_footer.date_text.clone(), footer: pres.header_footer.footer_text.clone() };
|
||||
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
@@ -76,32 +75,41 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
|
||||
place(out, rctx, fields, c, gm, depth + 1);
|
||||
}
|
||||
}
|
||||
ShapeKind::Table(_) | ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
|
||||
ShapeKind::Table(t) => {
|
||||
// Cell text, laid out exactly as the table draws it.
|
||||
for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
|
||||
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
|
||||
let body = table::cell_body(rctx, t, r, c, cell);
|
||||
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
||||
place_text(out, rctx, fields, &tmp, &body, rect, m);
|
||||
}
|
||||
}
|
||||
ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
|
||||
_ => {
|
||||
let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return };
|
||||
let tr = shape_geometry(s, x.w, x.h).text_rect;
|
||||
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
|
||||
let m = if layout.rotation != 0.0 {
|
||||
let c = tr.center().to_vec2();
|
||||
m * Affine::translate(c) * Affine::rotate(layout.rotation.to_radians()) * Affine::translate(-c)
|
||||
} else {
|
||||
m
|
||||
};
|
||||
// Text hyperlinks: one box per line segment of a linked run.
|
||||
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
|
||||
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
|
||||
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
|
||||
let x0 = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
|
||||
let last = run.glyphs.last().map(|g| g.1).unwrap_or(x0);
|
||||
let x1 = line.caret_x.get(run.chars.1.saturating_sub(line.start)).copied().unwrap_or(last).max(last);
|
||||
let r = Rect::new(x0, line.top, x1, line.bottom);
|
||||
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link });
|
||||
}
|
||||
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
|
||||
place_text(out, rctx, fields, s, body, tr, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks.
|
||||
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
|
||||
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
|
||||
let m = crate::text_transform(m, tr, layout.rotation);
|
||||
// Text hyperlinks: one box per line segment of a linked run.
|
||||
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
|
||||
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
|
||||
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
|
||||
// The run's visual extent (right-to-left runs run from right to left).
|
||||
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
|
||||
let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first));
|
||||
let r = Rect::new(x0, line.top, x1, line.bottom);
|
||||
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link });
|
||||
}
|
||||
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
|
||||
}
|
||||
|
||||
/// The hyperlink of the model run containing character `at` of paragraph `para`.
|
||||
fn run_link(body: &TextBody, para: usize, at: usize) -> Option<Hyperlink> {
|
||||
let p = body.paragraphs.get(para)?;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Gradient colour ramps as PowerPoint draws them.
|
||||
//!
|
||||
//! A gradient of exactly two stops, at 0 % and 100 % (what a two-colour gradient is), is not
|
||||
//! blended linearly in sRGB by PowerPoint: it mixes the colours in linear light, and the second
|
||||
//! colour's share follows an S-shaped curve of the position. Black to white is 105 (not 64) a
|
||||
//! quarter of the way, 186 (not 128) half way and 238 (not 191) three quarters of the way, in any
|
||||
//! direction, on shapes and on slide backgrounds. Gradients with three or more stops, or with a
|
||||
//! stop moved off an end, blend linearly in sRGB (measured from PowerPoint's own slide export).
|
||||
//! The renderer interpolates linearly in sRGB, so a two-colour gradient is expanded into short
|
||||
//! pieces whose colours follow PowerPoint's ramp.
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
|
||||
/// The next stop's share of the linear-light mix at sixteenths of a segment (0 at the first stop,
|
||||
/// 1 at the second). Measured, then made symmetric: `w(1 - t) = 1 - w(t)`.
|
||||
const WEIGHT: [f64; 17] =
|
||||
[0.0, 0.0205, 0.0469, 0.0878, 0.1431, 0.2143, 0.2989, 0.3972, 0.5, 0.6028, 0.7011, 0.7857, 0.8569, 0.9122, 0.9531, 0.9795, 1.0];
|
||||
|
||||
/// Pieces each segment between two stops is cut into.
|
||||
const PIECES: usize = 16;
|
||||
|
||||
/// The next stop's share at `t` (0..1) between two stops.
|
||||
pub fn weight(t: f64) -> f64 {
|
||||
if !t.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let x = t.clamp(0.0, 1.0) * (WEIGHT.len() - 1) as f64;
|
||||
let i = (x.floor() as usize).min(WEIGHT.len() - 2);
|
||||
let f = x - i as f64;
|
||||
let (a, b) = (WEIGHT.get(i).copied().unwrap_or(0.0), WEIGHT.get(i + 1).copied().unwrap_or(1.0));
|
||||
a + (b - a) * f
|
||||
}
|
||||
|
||||
fn to_linear(v: u8) -> f64 {
|
||||
let v = v as f64 / 255.0;
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
fn to_srgb(v: f64) -> u8 {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
let s = if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
(s * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
/// The colour at `t` between stops `a` and `b`. Alpha mixes by the same share.
|
||||
pub fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba {
|
||||
let w = weight(t);
|
||||
let ch = |x: u8, y: u8| to_srgb(to_linear(x) + (to_linear(y) - to_linear(x)) * w);
|
||||
Rgba { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: (a.a as f64 + (b.a as f64 - a.a as f64) * w).round().clamp(0.0, 255.0) as u8 }
|
||||
}
|
||||
|
||||
/// Sorted stops (position 0..1, colour) as a renderer interpolating linearly in sRGB should get
|
||||
/// them: a two-colour gradient (two stops, at 0 and 1) expanded to follow PowerPoint's ramp,
|
||||
/// anything else unchanged.
|
||||
pub fn expand(stops: &[(f64, Rgba)]) -> Vec<(f64, Rgba)> {
|
||||
let two_colour = matches!(stops, [(p, _), (q, _)] if *p <= 1e-6 && *q >= 1.0 - 1e-6);
|
||||
if !two_colour {
|
||||
return stops.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(stops.len() * PIECES + 1);
|
||||
for (i, &(p, c)) in stops.iter().enumerate() {
|
||||
out.push((p, c));
|
||||
let Some(&(q, d)) = stops.get(i + 1) else { continue };
|
||||
if q - p <= 1e-6 || c == d {
|
||||
continue;
|
||||
}
|
||||
for k in 1..PIECES {
|
||||
let t = k as f64 / PIECES as f64;
|
||||
out.push((p + (q - p) * t, mix(c, d, t)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn black_to_white_follows_powerpoint() {
|
||||
// PowerPoint's export: 105 at a quarter, 186 half way, 238 at three quarters.
|
||||
let (k, w) = (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255));
|
||||
for (t, want) in [(0.25, 105), (0.5, 186), (0.75, 238)] {
|
||||
let got = mix(k, w, t).r as i32;
|
||||
assert!((got - want).abs() <= 3, "{t}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_pairs_follow_powerpoint() {
|
||||
// Blue (4472C4) to orange (ED7D31) half way: PowerPoint draws (178, 120, 146).
|
||||
let m = mix(Rgba::rgb(68, 114, 196), Rgba::rgb(237, 125, 49), 0.5);
|
||||
assert!((m.r as i32 - 178).abs() <= 3 && (m.g as i32 - 120).abs() <= 3 && (m.b as i32 - 146).abs() <= 3, "{m:?}");
|
||||
// Red to blue a quarter of the way: (238, 0, 105).
|
||||
let m = mix(Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255), 0.25);
|
||||
assert!((m.r as i32 - 238).abs() <= 3 && (m.b as i32 - 105).abs() <= 3, "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ends_are_the_stops_and_the_weight_is_symmetric() {
|
||||
let (a, b) = (Rgba::rgb(10, 200, 30), Rgba::rgb(240, 20, 90));
|
||||
assert_eq!(mix(a, b, 0.0), a);
|
||||
assert_eq!(mix(a, b, 1.0), b);
|
||||
for k in 0..=32 {
|
||||
let t = k as f64 / 32.0;
|
||||
assert!((weight(t) + weight(1.0 - t) - 1.0).abs() < 1e-9);
|
||||
assert!(weight(t) <= weight((t + 0.01).min(1.0)), "monotonic");
|
||||
}
|
||||
assert_eq!(weight(f64::NAN), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_keeps_stops_and_hard_edges() {
|
||||
let (r, g) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 255, 0));
|
||||
// Three or more stops, or a stop off an end, blend linearly in sRGB: unchanged.
|
||||
let e = expand(&[(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
|
||||
assert_eq!(e, vec![(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
|
||||
assert_eq!(expand(&[(0.2, r), (1.0, g)]), vec![(0.2, r), (1.0, g)]);
|
||||
assert_eq!(expand(&[(0.0, r), (0.5, g), (1.0, r)]).len(), 3);
|
||||
// Two equal colours add nothing.
|
||||
assert_eq!(expand(&[(0.0, r), (1.0, r)]), vec![(0.0, r), (1.0, r)]);
|
||||
let e = expand(&[(0.0, r), (1.0, g)]);
|
||||
assert_eq!(e.len(), PIECES + 1);
|
||||
assert_eq!(e.first(), Some(&(0.0, r)));
|
||||
assert_eq!(e.last(), Some(&(1.0, g)));
|
||||
assert!(e.windows(2).all(|w| w[0].0 <= w[1].0));
|
||||
}
|
||||
}
|
||||
+73
-39
@@ -3,9 +3,10 @@
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::table::Table;
|
||||
use deckcraft_model::text::Anchor;
|
||||
use deckcraft_model::table::{Cell, Table};
|
||||
use deckcraft_model::text::{Anchor, TextBody, TextDir};
|
||||
use deckcraft_model::{Presentation, Shape};
|
||||
use deckcraft_text::Fields;
|
||||
use kurbo::{Affine, BezPath, Rect, Shape as _};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
@@ -94,21 +95,76 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
}
|
||||
}
|
||||
|
||||
/// Running edges 0, s0, s0+s1, … of a list of sizes (negative sizes count as 0).
|
||||
fn edges(sizes: &[f64]) -> Vec<f64> {
|
||||
std::iter::once(0.0)
|
||||
.chain(sizes.iter().scan(0.0, |a, s| {
|
||||
*a += s.max(0.0);
|
||||
Some(*a)
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
|
||||
pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
|
||||
let lk = look(rctx, t, r, c);
|
||||
let mut body = cell.text.clone();
|
||||
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
|
||||
body.body.inset_l = Some(ml);
|
||||
body.body.inset_r = Some(mr);
|
||||
body.body.inset_t = Some(mt);
|
||||
body.body.inset_b = Some(mb);
|
||||
body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
|
||||
if cell.vertical.is_some() {
|
||||
body.body.vert = cell.vertical;
|
||||
}
|
||||
for p in &mut body.paragraphs {
|
||||
for run in &mut p.runs {
|
||||
if run.props.fill.is_none()
|
||||
&& let Some(tc) = lk.text
|
||||
{
|
||||
run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
|
||||
}
|
||||
if lk.bold && run.props.bold.is_none() {
|
||||
run.props.bold = Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Heights the rows are drawn at. A stored row height (`a:tr/@h`) is a minimum: the row grows so the
|
||||
/// text of each of its cells fits, as in PowerPoint. Files from pandoc or python-pptx store 0.
|
||||
/// Cells spanning several rows and vertical text keep the stored height.
|
||||
pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
|
||||
let xs = edges(&t.cols);
|
||||
let mut out = Vec::with_capacity(t.rows.len());
|
||||
for (ri, row) in t.rows.iter().enumerate() {
|
||||
let mut h = row.height.max(0.0);
|
||||
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
|
||||
if cell.is_covered() || cell.row_span > 1 || !matches!(cell.vertical, None | Some(TextDir::Horizontal)) {
|
||||
continue;
|
||||
}
|
||||
let c1 = ci.saturating_add(cell.grid_span.max(1) as usize).min(t.cols.len());
|
||||
let (Some(x0), Some(x1)) = (xs.get(ci), xs.get(c1)) else { continue };
|
||||
let body = cell_body(rctx, t, ri, ci, cell);
|
||||
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
||||
let need = deckcraft_text::fit_height(rctx, &tmp, &body, Rect::new(*x0, 0.0, *x1, h.max(1.0)), fields);
|
||||
if need.is_finite() {
|
||||
h = h.max(need.min(20_000.0));
|
||||
}
|
||||
}
|
||||
out.push(h);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Cell rectangles in table-local space, honouring merges: (row, col, rect) for visible cells.
|
||||
pub fn cell_rects(t: &Table) -> Vec<(usize, usize, Rect)> {
|
||||
/// `heights` are the drawn row heights ([`row_heights`]).
|
||||
pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
||||
let mut out = vec![];
|
||||
let xs: Vec<f64> = std::iter::once(0.0)
|
||||
.chain(t.cols.iter().scan(0.0, |a, w| {
|
||||
*a += w.max(0.0);
|
||||
Some(*a)
|
||||
}))
|
||||
.collect();
|
||||
let ys: Vec<f64> = std::iter::once(0.0)
|
||||
.chain(t.rows.iter().scan(0.0, |a, r| {
|
||||
*a += r.height.max(0.0);
|
||||
Some(*a)
|
||||
}))
|
||||
.collect();
|
||||
let xs = edges(&t.cols);
|
||||
let ys = edges(heights);
|
||||
for (ri, row) in t.rows.iter().enumerate() {
|
||||
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
|
||||
if cell.is_covered() {
|
||||
@@ -125,7 +181,7 @@ pub fn cell_rects(t: &Table) -> Vec<(usize, usize, Rect)> {
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t);
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
for (r, c, rect) in &rects {
|
||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||
let lk = look(rctx, t, *r, *c);
|
||||
@@ -180,29 +236,7 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
|
||||
if cell.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let lk = look(rctx, t, *r, *c);
|
||||
let mut body = cell.text.clone();
|
||||
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
|
||||
body.body.inset_l = Some(ml);
|
||||
body.body.inset_r = Some(mr);
|
||||
body.body.inset_t = Some(mt);
|
||||
body.body.inset_b = Some(mb);
|
||||
body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
|
||||
if cell.vertical.is_some() {
|
||||
body.body.vert = cell.vertical;
|
||||
}
|
||||
for p in &mut body.paragraphs {
|
||||
for run in &mut p.runs {
|
||||
if run.props.fill.is_none()
|
||||
&& let Some(tc) = lk.text
|
||||
{
|
||||
run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
|
||||
}
|
||||
if lk.bold && run.props.bold.is_none() {
|
||||
run.props.bold = Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = cell_body(rctx, t, *r, *c, cell);
|
||||
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
||||
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
||||
let _ = peniko::Fill::NonZero;
|
||||
|
||||
@@ -157,3 +157,478 @@ fn custom_path_parse() {
|
||||
assert!(p.elements().len() >= 5);
|
||||
assert!(parse_path("").elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rows_of_zero_height_grow_to_fit_text() {
|
||||
// pandoc and python-pptx write `<a:tr h="0">`: the stored row height is only a minimum.
|
||||
let mut t = Table::new(3, 2, 300.0, 0.0);
|
||||
for (r, row) in [["Col A", "Col B"], ["1", "alpha"], ["2", "beta"]].iter().enumerate() {
|
||||
for (c, text) in row.iter().enumerate() {
|
||||
t.cell_mut(r, c).unwrap().text = TextBody::from_text(text);
|
||||
}
|
||||
}
|
||||
let tbl = Shape { id: ShapeId(610), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 0.0)), kind: ShapeKind::Table(t.clone()), ..Default::default() };
|
||||
let p = deck_with(vec![tbl]);
|
||||
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||||
let hs = crate::table::row_heights(&ctx, &t, &deckcraft_text::NoFields);
|
||||
assert_eq!(hs.len(), 3);
|
||||
assert!(hs.iter().all(|h| *h > 10.0), "{hs:?}");
|
||||
let tops: Vec<f64> = crate::table::cell_rects(&t, &hs).iter().filter(|(_, c, _)| *c == 0).map(|(_, _, r)| r.y0).collect();
|
||||
assert!(tops.len() == 3 && tops.windows(2).all(|w| w[1] > w[0] + 10.0), "{tops:?}");
|
||||
// A taller stored height is kept.
|
||||
let mut tall = t;
|
||||
tall.rows[1].height = 200.0;
|
||||
assert_eq!(crate::table::row_heights(&ctx, &tall, &deckcraft_text::NoFields)[1], 200.0);
|
||||
// The PDF text layer gets every cell's text, rows one below the other.
|
||||
let placed = place_slide(&p, 0);
|
||||
let top = |s: &str| {
|
||||
let pt = placed.texts.iter().find(|pt| pt.body.text() == s).unwrap_or_else(|| panic!("no placed text {s:?}"));
|
||||
(pt.transform * kurbo::Point::new(0.0, pt.layout.lines[0].top)).y
|
||||
};
|
||||
assert!(top("Col A") < top("1") && top("1") < top("2"));
|
||||
assert!((top("alpha") - top("1")).abs() < 1.0 && (top("beta") - top("2")).abs() < 1.0);
|
||||
assert!(top("Col B") >= 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_ends_match_the_slides_and_mixes_backdrops() {
|
||||
let sh = Shape {
|
||||
id: ShapeId(500),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = deck_with(vec![sh.clone()]);
|
||||
let mut dark = (*p.slides[0]).clone();
|
||||
dark.background =
|
||||
Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))) });
|
||||
p.slides.push(std::sync::Arc::new(dark));
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let shapes = vec![(sh, false)];
|
||||
// At the ends the frame is the slide itself; the shape stays opaque throughout.
|
||||
assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels);
|
||||
let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &[], &o);
|
||||
let px = mid.pixel(5, 5);
|
||||
assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}");
|
||||
assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75));
|
||||
// Hostile input: bad indices and NaN never panic.
|
||||
assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &[], &o).width, 0);
|
||||
let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o);
|
||||
}
|
||||
|
||||
fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape {
|
||||
let mut t = TextBody::from_text(text);
|
||||
for p in &mut t.paragraphs {
|
||||
for r in &mut p.runs {
|
||||
r.props.size = Some(40.0);
|
||||
}
|
||||
}
|
||||
Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, y, 600.0, 120.0)), text_box: true, text: Some(t), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_text_tokens_are_words_or_characters() {
|
||||
let p = Presentation::default();
|
||||
let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||||
let s = text_shape(1, "Hello big world\nHello", 0.0, 0.0);
|
||||
let l = deckcraft_text::layout(
|
||||
&rctx,
|
||||
&s,
|
||||
s.text.as_ref().unwrap(),
|
||||
&Opts { rect: Rect::new(0.0, 0.0, 600.0, 120.0), fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
);
|
||||
assert_eq!(morph_text::token_keys(&l, false), ["Hello", "big", "world", "Hello"]);
|
||||
assert_eq!(morph_text::token_keys(&l, true).concat(), "Hellobigworld".to_string() + "Hello");
|
||||
assert_eq!(morph_text::token_keys(&l, true).len(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_text_ends_match_the_slides() {
|
||||
let from = Xfrm::new(100.0, 60.0, 600.0, 120.0);
|
||||
let to = Xfrm::new(200.0, 300.0, 600.0, 120.0);
|
||||
let mut p = deck_with(vec![text_shape(1, "Hello wide world", from.x, from.y)]);
|
||||
let mut b = (*p.slides[0]).clone();
|
||||
b.shapes = vec![text_shape(1, "world says Hello", to.x, to.y)];
|
||||
p.slides.push(std::sync::Arc::new(b));
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let frame = |t: f64| {
|
||||
let x = Xfrm::new(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, 600.0, 120.0);
|
||||
let mut sb = text_shape(10, "world says Hello", 0.0, 0.0);
|
||||
let mut sa = text_shape(11, "Hello wide world", 0.0, 0.0);
|
||||
sb.xfrm = Some(x);
|
||||
sa.xfrm = Some(x);
|
||||
let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t };
|
||||
render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &[], &o)
|
||||
};
|
||||
// Warm up: faces load lazily (a background scan runs), so the first render may still fall back.
|
||||
let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5));
|
||||
let close = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 8).count();
|
||||
let d0 = close(&frame(0.0), &render_slide(&p, 0, &o));
|
||||
assert!(d0 < 40, "start differs {d0}");
|
||||
let d1 = close(&frame(1.0), &render_slide(&p, 1, &o));
|
||||
assert!(d1 < 40, "end differs {d1}");
|
||||
// Halfway the shared words are on their way: ink between the two texts' rows.
|
||||
let mid = frame(0.5);
|
||||
let ink = (95..140).flat_map(|y| (0..480).map(move |x| (x, y))).filter(|&(x, y)| mid.pixel(x, y)[0] < 128).count();
|
||||
assert!(ink > 50, "ink {ink}");
|
||||
}
|
||||
|
||||
/// A text box holding one run of `kind` (a field type, or plain text for `None`) saved as `saved`.
|
||||
fn field_shape(kind: Option<&str>, saved: &str) -> Shape {
|
||||
let mut sh = text_shape(1, saved, 40.0, 40.0);
|
||||
if let (Some(k), Some(run)) = (kind, sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut())) {
|
||||
run.kind = deckcraft_model::text::RunKind::Field { field: k.into() };
|
||||
}
|
||||
sh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_fields_show_the_date_in_their_format() {
|
||||
use deckcraft_text::datetime::DateTime;
|
||||
let fri = DateTime::new(2026, 10, 9, 20, 5, 7);
|
||||
let at = |p: &Presentation, now: DateTime| render_slide(p, 0, &RenderOpts { scale: 0.5, now: Some(now), ..Default::default() });
|
||||
let mut p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
|
||||
let _ = at(&p, fri); // faces load lazily
|
||||
// The field shows the date (it changes with the clock), not its saved text.
|
||||
let a = at(&p, fri);
|
||||
assert!(diff_px(&a, &at(&p, DateTime::new(2027, 3, 4, 0, 0, 0))) > 0);
|
||||
let shown = deck_with(vec![field_shape(None, "10/9/2026")]);
|
||||
assert_eq!(diff_px(&a, &at(&shown, fri)), 0, "datetime1 is M/D/YYYY");
|
||||
// Another slide's fixed date (a deck-wide Header & Footer text) doesn't replace it.
|
||||
p.header_footer.date_text = "Fixed on another slide".into();
|
||||
assert_eq!(diff_px(&a, &at(&p, fri)), 0);
|
||||
// Every type has its format; the run's language orders numeric dates.
|
||||
let f = Renderer::fields(&p, 0, &RenderOpts { now: Some(fri), ..Default::default() });
|
||||
assert_eq!(f.field_in("datetime4", Some("en-US")).as_deref(), Some("October 9, 2026"));
|
||||
assert_eq!(f.field_in("datetimeFigureOut", Some("en-GB")).as_deref(), Some("09/10/2026"));
|
||||
assert_eq!(f.field("datetime13").as_deref(), Some("8:05:07 PM"));
|
||||
assert_eq!(f.field("slidenum").as_deref(), Some("1"));
|
||||
// A date the app can't compute (no clock, unknown type, other-language names) keeps the saved text.
|
||||
assert_eq!(SlideFields { now: None, ..f }.field("datetime1"), None);
|
||||
let unknown = deck_with(vec![field_shape(Some("datetime99"), "Saved text")]);
|
||||
let plain = deck_with(vec![field_shape(None, "Saved text")]);
|
||||
assert_eq!(diff_px(&at(&unknown, fri), &at(&plain, fri)), 0);
|
||||
// An explicit override still wins.
|
||||
let o = RenderOpts { date_text: Some("Today".into()), now: Some(fri), ..Default::default() };
|
||||
assert_eq!(Renderer::fields(&p, 0, &o).field("datetime2").as_deref(), Some("Today"));
|
||||
}
|
||||
|
||||
/// Channel values that differ noticeably between two images of the same size.
|
||||
fn diff_px(a: &Image, b: &Image) -> usize {
|
||||
a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count()
|
||||
}
|
||||
|
||||
/// Two 8×8 frames, red then blue, 100 ms each.
|
||||
fn two_frame_gif() -> Vec<u8> {
|
||||
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 out = Vec::new();
|
||||
image::codecs::gif::GifEncoder::new(&mut out).encode_frames([frame([255, 0, 0, 255]), frame([0, 0, 255, 255])]).expect("gif");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_gif_shows_the_frame_for_its_time() {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("a.gif", "image/gif", two_frame_gif());
|
||||
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let at = |t: Option<f64>| {
|
||||
let times: Vec<(ShapeId, f64)> = t.map(|t| vec![(ShapeId(600), t)]).unwrap_or_default();
|
||||
render_slide(&p, 0, &RenderOpts { scale: 0.1, gif_times: ×, ..Default::default() }).pixel(48, 27)
|
||||
};
|
||||
let red = |px: [u8; 4]| px[0] > 200 && px[2] < 60;
|
||||
let blue = |px: [u8; 4]| px[2] > 200 && px[0] < 60;
|
||||
assert!(red(at(None)), "no time: the first frame, as exports draw it");
|
||||
assert!(red(at(Some(0.05))));
|
||||
assert!(blue(at(Some(0.15))));
|
||||
assert!(red(at(Some(0.25))), "it loops");
|
||||
let bytes = p.media(media).map(|m| m.data.clone()).expect("media");
|
||||
let adj = Default::default();
|
||||
assert_eq!(gif_frame(&bytes, &adj, 0.15).map(|f| f.0), Some(1));
|
||||
assert_eq!(gif_frame(&bytes, &adj, f64::NAN).map(|f| f.0), Some(0));
|
||||
assert!(gif_frame(&std::sync::Arc::new(b"\x89PNG".to_vec()), &adj, 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_path_ends_match_the_slides() {
|
||||
let from = Xfrm::new(100.0, 100.0, 200.0, 200.0);
|
||||
let to = Xfrm::new(500.0, 150.0, 300.0, 150.0);
|
||||
let form = |id: u32, g: &str, x: Xfrm| Shape {
|
||||
id: ShapeId(id),
|
||||
xfrm: Some(x),
|
||||
geom: Geom::preset(g),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = deck_with(vec![form(1, "ellipse", from)]);
|
||||
let mut b = (*p.slides[0]).clone();
|
||||
b.shapes = vec![form(1, "star5", to)];
|
||||
p.slides.push(std::sync::Arc::new(b));
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let frame = |t: f64| {
|
||||
let lerp = |a: f64, b: f64| a + (b - a) * t;
|
||||
let x = Xfrm::new(lerp(from.x, to.x), lerp(from.y, to.y), lerp(from.w, to.w), lerp(from.h, to.h));
|
||||
let pm = PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t };
|
||||
render_blend(&p, 0, 1, t, &[(form(10, "star5", x), false)], &[], &[pm], &o)
|
||||
};
|
||||
// The morph splits the outlines into more curves, which flatten slightly differently: edge pixels
|
||||
// may differ a little, a different shape differs a lot.
|
||||
let differ = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 60).count();
|
||||
// At the start the star is still the circle, at the end the star.
|
||||
let d0 = differ(&frame(0.0), &render_slide(&p, 0, &o));
|
||||
assert!(d0 < 40, "start differs {d0}");
|
||||
let d1 = differ(&frame(1.0), &render_slide(&p, 1, &o));
|
||||
assert!(d1 < 40, "end differs {d1}");
|
||||
// Without the morph the start would show the star.
|
||||
let star = render_blend(&p, 0, 1, 0.0, &[(form(10, "star5", from), false)], &[], &[], &o);
|
||||
assert!(differ(&star, &render_slide(&p, 0, &o)) > 400);
|
||||
// Halfway the shape is filled around the middle of its box.
|
||||
let mid = frame(0.5);
|
||||
let c = (from.center().to_vec2() + to.center().to_vec2()) * 0.5 * 0.5;
|
||||
assert_ne!(mid.pixel(c.x as u32, c.y as u32), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_path_fades_a_fill_only_one_side_has() {
|
||||
let bx = Xfrm::new(100.0, 100.0, 200.0, 200.0);
|
||||
let form = |id: u32, g: &str| Shape {
|
||||
id: ShapeId(id),
|
||||
xfrm: Some(bx),
|
||||
geom: Geom::preset(g),
|
||||
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = deck_with(vec![form(1, "rect")]);
|
||||
let mut b = (*p.slides[0]).clone();
|
||||
b.shapes = vec![form(1, "line")];
|
||||
p.slides.push(std::sync::Arc::new(b));
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let at = |t: f64| {
|
||||
let pm = PathMorph { id: ShapeId(10), from: Geom::preset("rect"), from_box: bx, to_box: bx, t };
|
||||
// A point near the line but not on it: inside the shape all the way.
|
||||
render_blend(&p, 0, 1, t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90)
|
||||
};
|
||||
let (full, half, none) = (at(0.0), at(0.5), at(1.0));
|
||||
assert_eq!(none, [255, 255, 255, 255]);
|
||||
assert!(full[0] < 128, "{full:?}");
|
||||
// Halfway the fill is half there: between the full colour and the white slide.
|
||||
assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_transform_unmirrors_flips() {
|
||||
// Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text.
|
||||
let tr = Rect::new(10.0, 0.0, 40.0, 50.0);
|
||||
let at = |x: Xfrm, p: Point| text_transform(x.affine(), tr, 0.0) * p;
|
||||
let near = |a: Point, b: Point| (a - b).hypot() < 1e-9;
|
||||
let base = Xfrm::new(0.0, 0.0, 100.0, 50.0);
|
||||
// flipH: the rect moves to 60..90 and the text still runs left to right.
|
||||
let h = Xfrm { flip_h: true, ..base };
|
||||
assert!(near(at(h, Point::new(10.0, 0.0)), Point::new(60.0, 0.0)));
|
||||
assert!(near(at(h, Point::new(40.0, 50.0)), Point::new(90.0, 50.0)));
|
||||
// flipV: the text is turned 180° in its (vertically mirrored) rect.
|
||||
let v = Xfrm { flip_v: true, ..base };
|
||||
assert!(near(at(v, Point::new(10.0, 0.0)), Point::new(40.0, 50.0)));
|
||||
assert!(near(at(v, Point::new(40.0, 50.0)), Point::new(10.0, 0.0)));
|
||||
// Both flips: the same as a 180° turn.
|
||||
let hv = Xfrm { flip_h: true, flip_v: true, ..base };
|
||||
let turned = Xfrm { rot: 180.0, ..base };
|
||||
for p in [Point::new(10.0, 0.0), Point::new(40.0, 50.0), Point::new(25.0, 10.0)] {
|
||||
assert!(near(at(hv, p), at(turned, p)));
|
||||
}
|
||||
// Unflipped and hostile input: plain transform, nothing NaN from the helper itself.
|
||||
assert_eq!(text_transform(base.affine(), tr, 0.0), base.affine());
|
||||
let _ = text_transform(h.affine(), Rect::new(f64::NAN, 0.0, f64::INFINITY, 1.0), f64::NAN);
|
||||
}
|
||||
|
||||
/// Slide with text box `x` holding left-aligned, asymmetric text.
|
||||
fn flipped_text(x: Xfrm, group: Option<Xfrm>) -> Image {
|
||||
let mut s = text_shape(1, "Fly 42 RJ", 0.0, 0.0);
|
||||
s.xfrm = Some(x);
|
||||
let s = match group {
|
||||
Some(g) => Shape {
|
||||
id: ShapeId(2),
|
||||
xfrm: Some(g),
|
||||
kind: ShapeKind::Group { children: vec![s], child: Xfrm::new(0.0, 0.0, 600.0, 120.0) },
|
||||
..Default::default()
|
||||
},
|
||||
None => s,
|
||||
};
|
||||
render_slide(&deck_with(vec![s]), 0, &RenderOpts { scale: 0.5, ..Default::default() })
|
||||
}
|
||||
|
||||
fn differing(a: &Image, b: &Image) -> usize {
|
||||
a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_shapes_draw_readable_text() {
|
||||
let base = Xfrm::new(180.0, 200.0, 600.0, 120.0);
|
||||
let plain = flipped_text(base, None);
|
||||
// Warm up: faces load lazily, so the first render may still fall back.
|
||||
let plain = if differing(&plain, &flipped_text(base, None)) > 0 { flipped_text(base, None) } else { plain };
|
||||
let turned = flipped_text(Xfrm { rot: 180.0, ..base }, None);
|
||||
assert!(differing(&plain, &turned) > 200, "the comparisons below must mean something");
|
||||
// flipH: text as unflipped (the text box is symmetric). flipV and both: turned 180°.
|
||||
let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..base }, None));
|
||||
assert!(d < 40, "flipH mirrored the text: {d}");
|
||||
let d = differing(&turned, &flipped_text(Xfrm { flip_v: true, ..base }, None));
|
||||
assert!(d < 40, "flipV mirrored the text: {d}");
|
||||
let d = differing(&turned, &flipped_text(Xfrm { flip_h: true, flip_v: true, ..base }, None));
|
||||
assert!(d < 40, "flipH+flipV: {d}");
|
||||
// Rotation then flipH: still the rotated text, not its mirror image.
|
||||
let rot = Xfrm { rot: 30.0, ..base };
|
||||
let d = differing(&flipped_text(rot, None), &flipped_text(Xfrm { flip_h: true, ..rot }, None));
|
||||
assert!(d < 40, "rotated flipH: {d}");
|
||||
// Groups: a flipped group's text isn't mirrored; a flipped child in a flipped group neither.
|
||||
let child = Xfrm::new(0.0, 0.0, 600.0, 120.0);
|
||||
let d = differing(&plain, &flipped_text(child, Some(Xfrm { flip_h: true, ..base })));
|
||||
assert!(d < 40, "group flipH: {d}");
|
||||
let d = differing(&plain, &flipped_text(Xfrm { flip_h: true, ..child }, Some(Xfrm { flip_h: true, ..base })));
|
||||
assert!(d < 40, "flipH child in flipH group: {d}");
|
||||
let d = differing(&turned, &flipped_text(child, Some(Xfrm { flip_v: true, ..base })));
|
||||
assert!(d < 40, "group flipV: {d}");
|
||||
}
|
||||
|
||||
/// A text box with one run of `props` (40 pt).
|
||||
fn run_deck(props: deckcraft_model::RunProps) -> Presentation {
|
||||
let mut sh = text_shape(1, "Linked text", 40.0, 40.0);
|
||||
if let Some(run) = sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut()) {
|
||||
run.props = deckcraft_model::RunProps { size: Some(40.0), ..props };
|
||||
}
|
||||
deck_with(vec![sh])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
|
||||
use deckcraft_model::RunProps;
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
let o = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
let px = |p: &Presentation| render_slide(p, 0, &o);
|
||||
let diff = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count();
|
||||
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(deckcraft_color::SchemeSlot::Hlink)));
|
||||
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))));
|
||||
let _ = px(&run_deck(RunProps::default())); // faces load lazily
|
||||
let linked = px(&run_deck(RunProps { link: link.clone(), ..Default::default() }));
|
||||
assert!(diff(&linked, &px(&run_deck(RunProps::default()))) > 100, "a link doesn't look like plain text");
|
||||
// Exactly the theme's hyperlink colour, single underline.
|
||||
let styled = px(&run_deck(RunProps { fill: hlink, underline: Some("sng".into()), ..Default::default() }));
|
||||
assert_eq!(diff(&linked, &styled), 0);
|
||||
// A colour on the run itself wins; u="none" drops the underline.
|
||||
let own = px(&run_deck(RunProps { link: link.clone(), fill: red.clone(), underline: Some("none".into()), ..Default::default() }));
|
||||
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red.clone(), ..Default::default() }))), 0);
|
||||
let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() }));
|
||||
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linear_gradient_ramps_like_powerpoint() {
|
||||
// Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way
|
||||
// (linear-light mix, S-shaped), not 64 and 128.
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
|
||||
let sh = Shape {
|
||||
id: ShapeId(700),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
for (x, want) in [(200, 105), (400, 186), (600, 238)] {
|
||||
let got = img.pixel(x, 50)[0] as i32;
|
||||
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_with_a_stop_off_the_end_stays_linear() {
|
||||
// Stops at 20 % and 100 %: PowerPoint blends linearly in sRGB (96 at 50 %, 175 at 75 %).
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
|
||||
let sh = Shape {
|
||||
id: ShapeId(701),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
for (x, want) in [(400, 96), (600, 175)] {
|
||||
let got = img.pixel(x, 50)[0] as i32;
|
||||
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_filled_with_the_background_shows_the_slide_background() {
|
||||
// `useBgFill`: PowerPoint paints the slide's background into the shape, laid out on the
|
||||
// slide. A white panel covers a black-to-grey background; a shape on it with the background
|
||||
// fill is a window onto that background.
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let none = || Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
|
||||
let panel = Shape {
|
||||
id: ShapeId(710),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
|
||||
line: none(),
|
||||
..Default::default()
|
||||
};
|
||||
let window = Shape {
|
||||
id: ShapeId(711),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 760.0, 340.0)),
|
||||
fill: Some(Fill::Background),
|
||||
line: none(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = deck_with(vec![panel, window]);
|
||||
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill {
|
||||
fill: Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
}),
|
||||
});
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!(img.pixel(50, 50)[0], 255, "the panel stays white outside the window");
|
||||
assert!(img.pixel(300, 270)[0] < 8, "the window shows the background's black half: {:?}", img.pixel(300, 270));
|
||||
// Laid out on the slide: three quarters across the slide is half way from black to grey.
|
||||
let v = img.pixel(720, 270)[0] as i32;
|
||||
assert!((v - 100).abs() <= 6, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_is_laid_over_white() {
|
||||
// A background picture stretched into the bottom-right quarter (fillRect l=50% t=50%), and a
|
||||
// half-transparent colour: PowerPoint shows white around and under them, not transparency.
|
||||
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 200]))).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let mut p = deck_with(vec![]);
|
||||
let media = p.add_media("bg.png", "image/png", png.into_inner());
|
||||
let pf = PictureFill { media, mode: PictureMode::Stretch { fill_rect: [0.5, 0.5, 0.0, 0.0] }, ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(pf) });
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||||
assert_eq!(img.pixel(20, 20), [255, 255, 255, 255], "white where the picture doesn't reach");
|
||||
let q = img.pixel(400, 230);
|
||||
assert!(q[2] > 190 && q[0] < 10 && q[3] == 255, "{q:?}");
|
||||
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 });
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(see_through) });
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
|
||||
let g = img.pixel(20, 20);
|
||||
assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}");
|
||||
}
|
||||
|
||||
@@ -15,3 +15,8 @@ deckcraft-color = { workspace = true }
|
||||
deckcraft-fonts = { workspace = true }
|
||||
unicode-linebreak = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
|
||||
# The local clock for date fields (no clock on wasm: fields keep their saved text).
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Bidirectional text (UAX #9) for paragraph layout.
|
||||
//!
|
||||
//! The pipeline is the one the algorithm prescribes: embedding levels are resolved once for the
|
||||
//! whole logical paragraph (base level from `a:pPr/@rtl`); shaping and line breaking happen in
|
||||
//! logical order; then each line is reordered on its own ([`line_levels`] applies rule L1,
|
||||
//! [`visual_order`] rule L2). Rule L4 (mirrored brackets) is applied by the shaper, which draws
|
||||
//! mirrorable characters in right-to-left items with their mirrored glyphs.
|
||||
|
||||
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS};
|
||||
use unicode_bidi::{BidiInfo, Level, bidi_class};
|
||||
|
||||
/// Does anything in the text need the full algorithm? (Strong right-to-left, Arabic numbers or
|
||||
/// explicit embeddings.)
|
||||
fn needs_bidi(chars: &[char]) -> bool {
|
||||
chars.iter().any(|c| matches!(bidi_class(*c), R | AL | AN | LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI))
|
||||
}
|
||||
|
||||
/// Resolved embedding level of every character of a paragraph (before the line rules), with the
|
||||
/// paragraph level forced to 1 when `rtl`.
|
||||
pub(crate) fn levels(text: &str, chars: &[char], rtl: bool) -> Vec<u8> {
|
||||
let base = u8::from(rtl);
|
||||
if !rtl && !needs_bidi(chars) {
|
||||
return vec![0; chars.len()];
|
||||
}
|
||||
let info = BidiInfo::new(text, Some(if rtl { Level::rtl() } else { Level::ltr() }));
|
||||
// `levels` is per byte; take each character's first byte.
|
||||
text.char_indices().map(|(b, _)| info.levels.get(b).map(|l| l.number()).unwrap_or(base)).collect()
|
||||
}
|
||||
|
||||
/// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a
|
||||
/// segment/paragraph separator.
|
||||
fn l1_whitespace(c: BidiClass) -> bool {
|
||||
matches!(c, WS | FSI | LRI | RLI | PDI | BN | LRE | RLE | LRO | RLO | PDF)
|
||||
}
|
||||
|
||||
/// Rule L1 for one line: segment and paragraph separators, any whitespace run before them, and
|
||||
/// whitespace at the end of the line take the paragraph level.
|
||||
pub(crate) fn line_levels(chars: &[char], levels: &[u8], para_level: u8) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = levels.to_vec();
|
||||
out.resize(chars.len(), para_level);
|
||||
// Walk backwards: `trailing` is true while every character after this one (up to the line end
|
||||
// or a separator) is whitespace.
|
||||
let mut trailing = true;
|
||||
for (i, c) in chars.iter().enumerate().rev() {
|
||||
let class = bidi_class(*c);
|
||||
if matches!(class, S | B) {
|
||||
if let Some(l) = out.get_mut(i) {
|
||||
*l = para_level;
|
||||
}
|
||||
trailing = true;
|
||||
} else if l1_whitespace(class) {
|
||||
if trailing && let Some(l) = out.get_mut(i) {
|
||||
*l = para_level;
|
||||
}
|
||||
} else {
|
||||
trailing = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rule L2: the visual order of a line's characters (indices into `levels`, left to right). From
|
||||
/// the highest level down to the lowest odd level, every maximal run at that level or higher is
|
||||
/// reversed.
|
||||
pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
|
||||
let mut order: Vec<usize> = (0..levels.len()).collect();
|
||||
let Some(&max) = levels.iter().max() else { return order };
|
||||
let min_odd = levels.iter().copied().filter(|l| l % 2 == 1).min().unwrap_or(max.saturating_add(1));
|
||||
let mut lvl = max;
|
||||
while lvl >= min_odd && lvl > 0 {
|
||||
let mut i = 0;
|
||||
while i < order.len() {
|
||||
let at = |k: usize| order.get(k).and_then(|&j| levels.get(j)).copied().unwrap_or(0);
|
||||
if at(i) >= lvl {
|
||||
let start = i;
|
||||
while i < order.len() && at(i) >= lvl {
|
||||
i += 1;
|
||||
}
|
||||
if let Some(run) = order.get_mut(start..i) {
|
||||
run.reverse();
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
lvl -= 1;
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lv(s: &str, rtl: bool) -> Vec<u8> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
levels(s, &chars, rtl)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_arabic_latin_numbers_and_brackets() {
|
||||
let s = "سنة 2024 (Retrait) فقط";
|
||||
let l = lv(s, true);
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
assert_eq!(l.len(), chars.len());
|
||||
// Arabic letters at the paragraph level 1; European digits at 2; Latin at 2.
|
||||
assert_eq!(l[0], 1);
|
||||
assert_eq!(&l[4..8], &[2, 2, 2, 2]);
|
||||
assert_eq!(&l[10..17], &[2; 7]);
|
||||
assert_eq!(l[19], 1);
|
||||
// Brackets around Latin inside an RTL paragraph resolve to the embedding direction (N0).
|
||||
assert_eq!(l[9] % 2, 1, "( is right-to-left");
|
||||
assert_eq!(l[17] % 2, 1, ") is right-to-left");
|
||||
// Visual order, left to right: the Arabic words reversed, Latin and digits kept, the
|
||||
// bracket characters swapped (their glyphs are mirrored back by the shaper, rule L4).
|
||||
let order = visual_order(&line_levels(&chars, &l, 1));
|
||||
let vis: String = order.iter().map(|&i| chars[i]).collect();
|
||||
assert_eq!(vis, "طقف )Retrait( 2024 ةنس");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ltr_paragraph_fast_path_and_embedded_rtl() {
|
||||
assert_eq!(lv("Hello 123", false), vec![0; 9]);
|
||||
let s = "ab سن cd";
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let l = lv(s, false);
|
||||
assert_eq!(l, vec![0, 0, 0, 1, 1, 0, 0, 0]);
|
||||
let vis: String = visual_order(&l).iter().map(|&i| chars[i]).collect();
|
||||
assert_eq!(vis, "ab نس cd");
|
||||
// An RTL paragraph of Latin text: the text stays left to right inside it.
|
||||
assert_eq!(lv("ab", true), vec![2, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l1_resets_trailing_whitespace_and_tabs() {
|
||||
let chars: Vec<char> = "ab\tcd ".chars().collect();
|
||||
let l = line_levels(&chars, &[2, 2, 2, 2, 2, 2, 2], 1);
|
||||
assert_eq!(l, vec![2, 2, 1, 2, 2, 1, 1]);
|
||||
// Short level slices never panic.
|
||||
assert_eq!(line_levels(&['a', 'b'], &[], 1), vec![1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_order_edge_cases() {
|
||||
assert!(visual_order(&[]).is_empty());
|
||||
assert_eq!(visual_order(&[0, 0]), vec![0, 1]);
|
||||
assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]);
|
||||
// Numbers (level 2) inside RTL keep their order.
|
||||
assert_eq!(visual_order(&[1, 2, 2, 1]), vec![3, 1, 2, 0]);
|
||||
assert_eq!(visual_order(&[125, 125]), vec![1, 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Date and time fields (`a:fld type="datetime…"`): the text each field type shows for a moment,
|
||||
//! and the local clock.
|
||||
//!
|
||||
//! The field types and their formats follow ECMA-376 Part 1, §21.1.2.2.4 (`fld`): `datetime1`
|
||||
//! to `datetime13`, written as in PowerPoint's English (United States) Date and Time list.
|
||||
//! `datetime` and `datetimeFigureOut` show the language's short date. Numeric dates follow the
|
||||
//! run's language; formats with month or day names, or AM/PM, are only computed for English, so
|
||||
//! other languages keep the text the file was saved with.
|
||||
|
||||
/// A local date and time: what the clock read, or a fixed moment (tests, deterministic output).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DateTime {
|
||||
pub year: i32,
|
||||
/// 1–12.
|
||||
pub month: u32,
|
||||
/// 1–31.
|
||||
pub day: u32,
|
||||
/// 0–23.
|
||||
pub hour: u32,
|
||||
pub minute: u32,
|
||||
pub second: u32,
|
||||
}
|
||||
|
||||
impl DateTime {
|
||||
pub fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> Self {
|
||||
DateTime { year, month, day, hour, minute, second }
|
||||
}
|
||||
|
||||
/// The local date and time now (the user's time zone); `None` where there is no clock (wasm).
|
||||
pub fn now_local() -> Option<DateTime> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use chrono::{Datelike, Timelike};
|
||||
let n = chrono::Local::now();
|
||||
Some(DateTime::new(n.year(), n.month(), n.day(), n.hour(), n.minute(), n.second()))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A real calendar date and time of day.
|
||||
fn is_valid(&self) -> bool {
|
||||
let leap = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0);
|
||||
let days = match self.month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
_ => return false,
|
||||
};
|
||||
(1..=9999).contains(&self.year) && (1..=days).contains(&self.day) && self.hour < 24 && self.minute < 60 && self.second < 61
|
||||
}
|
||||
|
||||
/// Day of the week, 0 = Sunday (days since 1970-01-01, Howard Hinnant's algorithm).
|
||||
fn weekday(&self) -> usize {
|
||||
let (y, m, d) = (i64::from(self.year), i64::from(self.month), i64::from(self.day));
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146_097 + doe - 719_468;
|
||||
(days + 4).rem_euclid(7) as usize
|
||||
}
|
||||
}
|
||||
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
/// Is field `kind` a date/time field?
|
||||
pub fn is_date_field(kind: &str) -> bool {
|
||||
kind.starts_with("datetime")
|
||||
}
|
||||
|
||||
/// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g.
|
||||
/// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or
|
||||
/// the format needs names in a language other than English: the field keeps its saved text.
|
||||
pub fn field_text(kind: &str, lang: Option<&str>, now: DateTime) -> Option<String> {
|
||||
if !now.is_valid() {
|
||||
return None;
|
||||
}
|
||||
let lang = lang.map(str::trim).filter(|l| !l.is_empty()).unwrap_or("en-US").to_ascii_lowercase();
|
||||
let english = lang == "en" || lang.starts_with("en-");
|
||||
let month = MONTHS.get(now.month.checked_sub(1)? as usize)?;
|
||||
let mon = month.get(..3)?;
|
||||
let day = DAYS.get(now.weekday())?;
|
||||
let (d, y) = (now.day, now.year);
|
||||
let yy = y.rem_euclid(100);
|
||||
let h12 = match now.hour % 12 {
|
||||
0 => 12,
|
||||
h => h,
|
||||
};
|
||||
let ampm = if now.hour < 12 { "AM" } else { "PM" };
|
||||
let (mi, s) = (now.minute, now.second);
|
||||
let short = short_date(&lang, now);
|
||||
let named = |text: String| english.then_some(text);
|
||||
match kind {
|
||||
"datetime" | "datetimeFigureOut" | "datetime1" => Some(short),
|
||||
"datetime2" => named(format!("{day}, {month} {d}, {y}")),
|
||||
"datetime3" => named(format!("{d} {month} {y}")),
|
||||
"datetime4" => named(format!("{month} {d}, {y}")),
|
||||
"datetime5" => named(format!("{d}-{mon}-{yy:02}")),
|
||||
"datetime6" => named(format!("{month} {yy:02}")),
|
||||
"datetime7" => named(format!("{mon}-{yy:02}")),
|
||||
"datetime8" => named(format!("{short} {h12}:{mi:02} {ampm}")),
|
||||
"datetime9" => named(format!("{short} {h12}:{mi:02}:{s:02} {ampm}")),
|
||||
"datetime10" => Some(format!("{}:{mi:02}", now.hour)),
|
||||
"datetime11" => Some(format!("{}:{mi:02}:{s:02}", now.hour)),
|
||||
"datetime12" => named(format!("{h12}:{mi:02} {ampm}")),
|
||||
"datetime13" => named(format!("{h12}:{mi:02}:{s:02} {ampm}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The short (numeric) date of language `lang` (lower case), US order unless the language is
|
||||
/// known to write its dates another way.
|
||||
fn short_date(lang: &str, t: DateTime) -> String {
|
||||
let (y, m, d) = (t.year, t.month, t.day);
|
||||
let primary = lang.split(['-', '_']).next().unwrap_or("");
|
||||
match lang {
|
||||
"en-gb" | "en-au" | "en-nz" | "en-ie" | "en-in" => return format!("{d:02}/{m:02}/{y}"),
|
||||
"en-ca" | "fr-ca" | "en-za" => return format!("{y}-{m:02}-{d:02}"),
|
||||
_ => {}
|
||||
}
|
||||
match primary {
|
||||
"fr" | "es" | "it" | "pt" | "el" | "ca" | "vi" => format!("{d:02}/{m:02}/{y}"),
|
||||
"de" | "ru" | "pl" | "cs" | "sk" | "tr" | "fi" | "nb" | "nn" | "no" | "da" | "uk" | "ro" => format!("{d:02}.{m:02}.{y}"),
|
||||
"nl" => format!("{d}-{m}-{y}"),
|
||||
"ja" | "zh" => format!("{y}/{m}/{d}"),
|
||||
"ko" | "sv" | "lt" => format!("{y}-{m:02}-{d:02}"),
|
||||
_ => format!("{m}/{d}/{y}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FRI: DateTime = DateTime { year: 2026, month: 10, day: 9, hour: 20, minute: 5, second: 7 };
|
||||
|
||||
#[test]
|
||||
fn english_formats_of_every_type() {
|
||||
let f = |k: &str| field_text(k, Some("en-US"), FRI);
|
||||
assert_eq!(f("datetime1").as_deref(), Some("10/9/2026"));
|
||||
assert_eq!(f("datetime2").as_deref(), Some("Friday, October 9, 2026"));
|
||||
assert_eq!(f("datetime3").as_deref(), Some("9 October 2026"));
|
||||
assert_eq!(f("datetime4").as_deref(), Some("October 9, 2026"));
|
||||
assert_eq!(f("datetime5").as_deref(), Some("9-Oct-26"));
|
||||
assert_eq!(f("datetime6").as_deref(), Some("October 26"));
|
||||
assert_eq!(f("datetime7").as_deref(), Some("Oct-26"));
|
||||
assert_eq!(f("datetime8").as_deref(), Some("10/9/2026 8:05 PM"));
|
||||
assert_eq!(f("datetime9").as_deref(), Some("10/9/2026 8:05:07 PM"));
|
||||
assert_eq!(f("datetime10").as_deref(), Some("20:05"));
|
||||
assert_eq!(f("datetime11").as_deref(), Some("20:05:07"));
|
||||
assert_eq!(f("datetime12").as_deref(), Some("8:05 PM"));
|
||||
assert_eq!(f("datetime13").as_deref(), Some("8:05:07 PM"));
|
||||
assert_eq!(f("datetimeFigureOut").as_deref(), Some("10/9/2026"));
|
||||
assert_eq!(f("datetime").as_deref(), Some("10/9/2026"));
|
||||
// No language: US English. Midnight and noon are 12 on the clock.
|
||||
assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 0, 0, 0)).as_deref(), Some("12:00 AM"));
|
||||
assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 12, 30, 0)).as_deref(), Some("12:30 PM"));
|
||||
assert_eq!(field_text("datetime2", None, DateTime::new(2000, 1, 1, 0, 0, 0)).as_deref(), Some("Saturday, January 1, 2000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_orders_numeric_dates_and_keeps_names_english_only() {
|
||||
assert_eq!(field_text("datetime1", Some("en-GB"), FRI).as_deref(), Some("09/10/2026"));
|
||||
assert_eq!(field_text("datetimeFigureOut", Some("de-DE"), FRI).as_deref(), Some("09.10.2026"));
|
||||
assert_eq!(field_text("datetime1", Some("ja-JP"), FRI).as_deref(), Some("2026/10/9"));
|
||||
assert_eq!(field_text("datetime1", Some("sv-SE"), FRI).as_deref(), Some("2026-10-09"));
|
||||
assert_eq!(field_text("datetime10", Some("fr-FR"), FRI).as_deref(), Some("20:05"));
|
||||
// Month names in another language: not computed (the field keeps its saved text).
|
||||
assert_eq!(field_text("datetime2", Some("de-DE"), FRI), None);
|
||||
assert_eq!(field_text("datetime12", Some("fr-FR"), FRI), None);
|
||||
assert_eq!(field_text("datetime4", Some("en-GB"), FRI).as_deref(), Some("October 9, 2026"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_types_and_impossible_dates_give_nothing() {
|
||||
assert_eq!(field_text("slidenum", None, FRI), None);
|
||||
assert_eq!(field_text("datetime14", None, FRI), None);
|
||||
for bad in [
|
||||
DateTime::new(2026, 0, 9, 0, 0, 0),
|
||||
DateTime::new(2026, 13, 9, 0, 0, 0),
|
||||
DateTime::new(2026, 2, 29, 0, 0, 0),
|
||||
DateTime::new(2026, 4, 31, 0, 0, 0),
|
||||
DateTime::new(2026, 1, 1, 24, 0, 0),
|
||||
DateTime::new(i32::MIN, 1, 1, 0, 0, 0),
|
||||
DateTime::new(i32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX),
|
||||
] {
|
||||
assert_eq!(field_text("datetime2", None, bad), None, "{bad:?}");
|
||||
}
|
||||
assert!(is_date_field("datetimeFigureOut") && !is_date_field("slidenum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clock_reads_a_real_date() {
|
||||
if let Some(now) = DateTime::now_local() {
|
||||
assert!(field_text("datetime1", None, now).is_some(), "{now:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+799
-197
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use deckcraft_model::Fill;
|
||||
use deckcraft_model::defaults;
|
||||
use deckcraft_model::text::{Paragraph, Run};
|
||||
use deckcraft_model::{LayoutType, Presentation, Xfrm};
|
||||
@@ -44,6 +45,20 @@ fn bullets_are_drawn_for_body_text() {
|
||||
assert_eq!(l.para_lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_character_of_a_bullet_is_drawn() {
|
||||
// "••" (seen in the wild) draws one dot in PowerPoint.
|
||||
for (char, glyphs) in [("\u{2022}\u{2022}", 1), ("\u{2022}", 1), ("-->", 1)] {
|
||||
let (p, mut sh) = setup("One", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::Char { char: char.into() });
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
|
||||
assert_eq!(bullet.glyphs.len(), glyphs, "{char:?}");
|
||||
}
|
||||
// The first grapheme cluster is kept whole.
|
||||
assert_eq!(map_symbol_bullet("e\u{301}x", None), "e\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_on_overflow() {
|
||||
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
|
||||
@@ -121,3 +136,387 @@ fn numbered_list_counts() {
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
assert!(l.runs.len() >= 6);
|
||||
}
|
||||
|
||||
// ---------- Bidirectional text and Arabic typography ----------
|
||||
|
||||
const MIXED: &str = "سنة 2024 (Retrait) فقط";
|
||||
|
||||
/// A one-paragraph body with no bullet, `rtl` and `align` set.
|
||||
fn bidi_setup(runs: Vec<Run>, w: f64, rtl: bool, align: Align) -> (Presentation, Shape) {
|
||||
let (p, mut sh) = setup("x", w, 300.0);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.paragraphs = vec![Paragraph { runs, ..Default::default() }];
|
||||
let pp = &mut t.paragraphs[0].props;
|
||||
pp.rtl = Some(rtl);
|
||||
pp.align = Some(align);
|
||||
pp.bullet = Some(Bullet::None);
|
||||
pp.margin_left = Some(0.0);
|
||||
pp.indent = Some(0.0);
|
||||
(p, sh)
|
||||
}
|
||||
|
||||
/// Is any face able to draw Arabic here (installed or bundled)? Shaping assertions that need
|
||||
/// real Arabic glyphs are skipped without one.
|
||||
fn arabic_face() -> Option<Arc<FontFace>> {
|
||||
let db = FontDb::global();
|
||||
let f = db.cascade(&['\u{0633}'], &[db.face("Inter", "Regular")], "Regular");
|
||||
f.covers('\u{0633}').then_some(f)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_mixed_string_is_reordered_per_line() {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
let line = &l.lines[0];
|
||||
assert!(line.rtl);
|
||||
let n = MIXED.chars().count();
|
||||
assert_eq!(line.edges.len(), n);
|
||||
assert_eq!(line.caret_x.len(), n + 1);
|
||||
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
|
||||
// Visual order right to left: سنة | 2024 | (Retrait) | فقط.
|
||||
assert!(mid(0) > mid(4), "first Arabic word right of the number");
|
||||
assert!(mid(4) > mid(10), "number right of the Latin word");
|
||||
assert!(mid(10) > mid(19), "Latin right of the last Arabic word");
|
||||
// Inside the Arabic words, logical order runs right to left; digits and Latin left to right.
|
||||
assert!(mid(0) > mid(1) && mid(1) > mid(2));
|
||||
assert!(mid(4) < mid(5) && mid(5) < mid(7));
|
||||
assert!(mid(10) < mid(16));
|
||||
// The brackets sit outside "Retrait": "(" (logically first) on its right.
|
||||
assert!(mid(9) > mid(16) && mid(17) < mid(10));
|
||||
// Right-aligned at the start edge S = W - rIns; the downstream caret at 0 is that edge.
|
||||
let right_edge = l.inner.x1;
|
||||
assert!((line.extent.1 - right_edge).abs() < 0.5, "{} vs {right_edge}", line.extent.1);
|
||||
assert!((line.caret_x[0] - line.extent.1).abs() < 0.01);
|
||||
// Levels after L1: Arabic odd, digits and Latin even.
|
||||
assert_eq!(line.levels[0], 1);
|
||||
assert_eq!(line.levels[4], 2);
|
||||
assert_eq!(line.levels[10], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() {
|
||||
let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right);
|
||||
let pp = &mut sh.text.as_mut().unwrap().paragraphs[0].props;
|
||||
pp.bullet = Some(Bullet::Char { char: "•".into() });
|
||||
pp.margin_left = Some(36.0);
|
||||
pp.indent = Some(-36.0);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
// Text starts marL from the right edge; the bullet hangs at S - (marL + indent) = S.
|
||||
let s_edge = l.inner.x1;
|
||||
assert!((line.extent.1 - (s_edge - 36.0)).abs() < 0.5, "text right {} expected {}", line.extent.1, s_edge - 36.0);
|
||||
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
|
||||
let bx = bullet.glyphs[0].1;
|
||||
assert!(bx > line.extent.1 - 0.5, "bullet {bx} right of the text {}", line.extent.1);
|
||||
assert!(bx < s_edge, "bullet inside the box");
|
||||
// An LTR paragraph keeps the bullet on the left.
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.rtl = Some(false);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.align = Some(Align::Left);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
|
||||
assert!(bullet.glyphs[0].1 < l.lines[0].extent.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_left_and_center_alignment() {
|
||||
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Left);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
assert!((l.lines[0].extent.0 - l.inner.x0).abs() < 0.5, "algn=l is flush left in RTL too");
|
||||
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Center);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let c = (l.lines[0].extent.0 + l.lines[0].extent.1) / 2.0;
|
||||
assert!((c - (l.inner.x0 + l.inner.x1) / 2.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidi_caret_stops_round_trip_through_hit_testing() {
|
||||
for rtl in [true, false] {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, rtl, if rtl { Align::Right } else { Align::Left });
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
for (x, ch, aff) in line.visual_stops() {
|
||||
let (cx, ..) = l.caret_at(Pos { para: 0, ch }, aff).unwrap();
|
||||
assert!((cx - x).abs() < 0.01, "stop {ch} {aff:?}: caret {cx} vs stop {x}");
|
||||
}
|
||||
// Clicking just inside either side of each grapheme puts the caret on that side.
|
||||
for (a, b) in line.graphemes() {
|
||||
let Some((x0, x1)) = line.grapheme_box(a, b) else { continue };
|
||||
if x1 - x0 < 1.0 {
|
||||
continue;
|
||||
}
|
||||
let y = (line.top + line.bottom) / 2.0;
|
||||
for x in [x0 + 0.2, x1 - 0.2] {
|
||||
let (pos, aff) = l.hit_affinity(Point::new(x, y));
|
||||
let (cx, ..) = l.caret_at(pos, aff).unwrap();
|
||||
let side = if x < (x0 + x1) / 2.0 { x0 } else { x1 };
|
||||
assert!((cx - side).abs() < 0.01, "rtl={rtl} grapheme {a}: hit {x} -> {pos:?} {aff:?} caret {cx}, expected {side}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_movement_walks_the_line_left_to_right() {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let stops = l.lines[0].visual_stops();
|
||||
let (x0, ch0, aff0) = stops[0];
|
||||
let (mut pos, mut aff) = (Pos { para: 0, ch: ch0 }, aff0);
|
||||
let mut xs = vec![x0];
|
||||
for _ in 0..200 {
|
||||
let (np, na) = l.move_caret(pos, aff, true, MoveMode::Visual);
|
||||
if (np, na) == (pos, aff) {
|
||||
break;
|
||||
}
|
||||
(pos, aff) = (np, na);
|
||||
xs.push(l.caret_at(pos, aff).unwrap().0);
|
||||
}
|
||||
assert!(xs.windows(2).all(|w| w[1] > w[0]), "moving right always moves right: {xs:?}");
|
||||
let right = l.lines[0].extent.1;
|
||||
assert!((xs.last().unwrap() - right).abs() < 0.01, "reaches the right edge");
|
||||
// Right arrow at the right edge of an RTL paragraph's only line goes nowhere.
|
||||
assert_eq!(l.move_caret(pos, aff, true, MoveMode::Visual).0, pos);
|
||||
// Moving left from the right edge in an RTL paragraph is logically forward.
|
||||
let (p1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, false, MoveMode::Visual);
|
||||
assert_eq!(p1.ch, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_movement_steps_over_harakat() {
|
||||
// beh + fatha + teh: the fatha belongs to the beh's grapheme.
|
||||
let (p, sh) = bidi_setup(vec![Run::new("\u{0628}\u{064E}\u{062A}")], 400.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let (n1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, true, MoveMode::Logical);
|
||||
assert_eq!(n1.ch, 2);
|
||||
let (n2, _) = l.move_caret(n1, Affinity::Downstream, false, MoveMode::Logical);
|
||||
assert_eq!(n2.ch, 0);
|
||||
// No caret stop inside a grapheme.
|
||||
assert!(l.lines[0].visual_stops().iter().all(|s| s.1 != 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_is_the_union_of_visual_boxes() {
|
||||
// LTR paragraph with an Arabic word: visually "ab ةنس cd".
|
||||
let (p, sh) = bidi_setup(vec![Run::new("ab سنة cd")], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
// Logical [1, 5) = "b", " ", "س", "ن": two separate boxes on screen.
|
||||
let r = l.selection_rects(Pos { para: 0, ch: 1 }, Pos { para: 0, ch: 5 });
|
||||
assert_eq!(r.len(), 2, "{r:?}");
|
||||
// The whole line is one box.
|
||||
assert_eq!(l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9 }).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_only_style_changes_keep_arabic_joining() {
|
||||
let Some(_) = arabic_face() else {
|
||||
eprintln!("skipped: no Arabic face available");
|
||||
return;
|
||||
};
|
||||
let word = "سنة";
|
||||
let glyphs = |runs: Vec<Run>| {
|
||||
let (p, sh) = bidi_setup(runs, 400.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 400.0, 300.0);
|
||||
let mut g: Vec<(u32, f64)> = l.runs.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.0, g.1))).collect();
|
||||
g.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
g.into_iter().map(|g| g.0).collect::<Vec<_>>()
|
||||
};
|
||||
let whole = glyphs(vec![Run::new(word)]);
|
||||
let mut red = Run::new("سن");
|
||||
red.props.fill = Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(Rgba::from_hex("C00000").unwrap())));
|
||||
let split = glyphs(vec![red, Run::new("ة")]);
|
||||
assert_eq!(whole, split, "a colour change inside a word keeps the joined forms");
|
||||
// And the joined forms differ from the isolated letters.
|
||||
let isolated: Vec<u32> = word.chars().map(|c| arabic_face().unwrap().glyph_for(c)).collect();
|
||||
assert_ne!(whole.iter().rev().copied().collect::<Vec<_>>(), isolated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn just_low_stretches_arabic_with_kashida() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no Arabic face available");
|
||||
return;
|
||||
}
|
||||
// Tatweel glyphs drawn, counted in each run's own face (the one the layout picked).
|
||||
let tatweels = |l: &TextLayout| {
|
||||
l.runs
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let tg = r.face.glyph_for('\u{0640}');
|
||||
if tg == 0 { 0 } else { r.glyphs.iter().filter(|g| g.0 == tg).count() }
|
||||
})
|
||||
.sum::<usize>()
|
||||
};
|
||||
let text = "بسم الله الرحمن الرحيم نستعين به ونستغفره ونتوب إليه";
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::JustLow);
|
||||
let l = lay(&p, &sh, 220.0, 600.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
let first = &l.lines[0];
|
||||
let w = l.inner.width();
|
||||
assert!((first.extent.1 - first.extent.0 - w).abs() < 0.5, "first line fills the box: {:?} vs {w}", first.extent);
|
||||
if l.runs.iter().all(|r| r.face.glyph_for('\u{0640}') == 0) {
|
||||
eprintln!("skipped: the Arabic face has no tatweel");
|
||||
return;
|
||||
}
|
||||
assert!(tatweels(&l) > 0, "kashida drawn");
|
||||
// The last line isn't stretched and sits at the start (right) edge.
|
||||
let last = l.lines.last().unwrap();
|
||||
assert!((last.extent.1 - l.inner.x1).abs() < 0.5);
|
||||
// Plain justify uses spaces: no tatweel.
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::Justify);
|
||||
let l = lay(&p, &sh, 220.0, 600.0);
|
||||
assert_eq!(tatweels(&l), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_rtl_lines_break_in_logical_order() {
|
||||
let text = "واحد اثنان ثلاثة أربعة خمسة ستة سبعة ثمانية تسعة عشرة";
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 160.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 160.0, 600.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
// Lines cover the text in logical order: the first line holds the first words.
|
||||
assert_eq!(l.lines[0].start, 0);
|
||||
for w in l.lines.windows(2) {
|
||||
assert!(w[1].start >= w[0].end);
|
||||
}
|
||||
for line in &l.lines {
|
||||
assert!(line.extent.0 >= l.inner.x0 - 0.5, "line fits: {:?}", line.extent);
|
||||
assert!((line.extent.1 - l.inner.x1).abs() < 0.5, "flush right");
|
||||
}
|
||||
// Upstream affinity at a wrap keeps the caret on the earlier line.
|
||||
let end0 = l.lines[0].end;
|
||||
assert_eq!(l.line_of_affine(Pos { para: 0, ch: end0 }, Affinity::Upstream), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_bidi_input_never_panics() {
|
||||
let nasty = format!("{}\t\u{b}\u{202E}abc\u{2069}{}(]))[ ١٢٣ \u{0640}\u{064E}\u{064E}\u{200D}", "\u{202B}".repeat(200), "\u{2067}".repeat(130));
|
||||
for rtl in [true, false] {
|
||||
for align in [Align::Left, Align::Right, Align::Center, Align::Justify, Align::JustLow, Align::Distributed] {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(nasty.clone()), Run::new(MIXED)], 60.0, rtl, align);
|
||||
let l = lay(&p, &sh, 60.0, 60.0);
|
||||
for line in &l.lines {
|
||||
assert_eq!(line.caret_x.len(), line.end - line.start + 1);
|
||||
assert_eq!(line.edges.len(), line.end - line.start);
|
||||
}
|
||||
let _ = l.hit_affinity(Point::new(-1e9, 1e9));
|
||||
let _ = l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9999 });
|
||||
let mut pos = (Pos::default(), Affinity::Downstream);
|
||||
for i in 0..50 {
|
||||
pos = l.move_caret(pos.0, pos.1, i % 3 != 0, if i % 2 == 0 { MoveMode::Visual } else { MoveMode::Logical });
|
||||
}
|
||||
let _ = l.caret_at(Pos { para: 7, ch: 7 }, Affinity::Upstream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of `word` laid out in a run with `baseline` (0 = normal, 0.3 = superscript …).
|
||||
fn run_width(word: &str, baseline: Option<f64>) -> f64 {
|
||||
let mut run = Run::new(word);
|
||||
run.props.size = Some(24.0);
|
||||
run.props.baseline = baseline;
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
line.caret_x.last().unwrap() - line.caret_x.first().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_and_subscript_advance_at_their_drawn_size() {
|
||||
// The glyphs are drawn at two thirds of the size; advancing by the full size spread the
|
||||
// letters apart ("s u p e r").
|
||||
let normal = run_width("superscript", None);
|
||||
for b in [Some(0.3), Some(-0.25)] {
|
||||
let w = run_width("superscript", b);
|
||||
assert!((w / normal - SUB_SUPER).abs() < 0.02, "{b:?}: {w} vs {normal}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_glyphs_sit_inside_their_cells() {
|
||||
let mut run = Run::new("super");
|
||||
run.props.size = Some(24.0);
|
||||
run.props.baseline = Some(0.3);
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
assert!((r.size - 24.0 * SUB_SUPER).abs() < 1e-6);
|
||||
for (g, (ci, _)) in r.glyphs.iter().zip(&r.cells) {
|
||||
let (x0, x1) = line.edges[*ci - line.start];
|
||||
assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapping numbered paragraph with `marL` and `indent`; returns (bullet x, first line's text
|
||||
/// x, second line's text x).
|
||||
fn hanging(margin: f64, indent: f64, bullet: Bullet) -> (Option<f64>, f64, f64) {
|
||||
let text = "Wrapping words go on and on so the paragraph needs at least two or three lines here";
|
||||
let (p, mut sh) = setup(text, 300.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(bullet);
|
||||
para.props.margin_left = Some(margin);
|
||||
para.props.indent = Some(indent);
|
||||
let l = lay(&p, &sh, 300.0, 300.0);
|
||||
assert!(l.lines.len() >= 2, "{} lines", l.lines.len());
|
||||
let bx = l.runs.iter().find(|r| r.cells.is_empty() && !r.glyphs.is_empty()).map(|r| r.glyphs[0].1);
|
||||
(bx, l.lines[0].caret_x[0], l.lines[1].caret_x[0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hang_past_the_box_edge_keeps_its_width_on_the_first_line() {
|
||||
// PowerPoint, marL 0 / indent -36pt: the number sits at the box's edge, the first line's text
|
||||
// 36pt in, and the wrapped lines at the margin (observed with PowerPoint's slide export).
|
||||
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
|
||||
let (bx, first, rest) = hanging(0.0, -36.0, num());
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((bx.unwrap() - rest).abs() < 0.5, "bullet {bx:?} rest {rest}");
|
||||
// marL 18 / indent -54: number at the edge, first line at 54, the rest at 18.
|
||||
let (bx, first, rest) = hanging(18.0, -54.0, num());
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((rest - bx.unwrap() - 18.0).abs() < 0.5, "bullet {bx:?} rest {rest}");
|
||||
// A character bullet behaves the same.
|
||||
let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() });
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
// Without a bullet the first line just starts at the edge.
|
||||
let (_, first, rest) = hanging(0.0, -36.0, Bullet::None);
|
||||
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_hang_still_puts_the_text_at_the_margin() {
|
||||
let (bx, first, rest) = hanging(36.0, -36.0, Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 });
|
||||
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((rest - bx.unwrap() - 36.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() {
|
||||
// PowerPoint spaces single-spaced lines 1.2 × the font size apart in every font, with the
|
||||
// baseline where the font's Windows ascent/descent split puts it (observed on twenty fonts).
|
||||
for size in [18.0, 40.0] {
|
||||
let mut run = Run::new("HHH");
|
||||
run.props.size = Some(size);
|
||||
let (p, mut sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.autofit = Some(AutoFit::None);
|
||||
let second = t.paragraphs[0].clone();
|
||||
t.paragraphs.push(second.clone());
|
||||
t.paragraphs.push(second);
|
||||
for para in &mut t.paragraphs {
|
||||
para.props.line_spacing = Some(Spacing::Pct(1.0));
|
||||
para.props.space_before = Some(Spacing::Pts(0.0));
|
||||
para.props.space_after = Some(Spacing::Pts(0.0));
|
||||
}
|
||||
let l = lay(&p, &sh, 2000.0, 600.0);
|
||||
assert_eq!(l.lines.len(), 3);
|
||||
// Each line is 1.2 × the size tall (the face is not compared across lines: another
|
||||
// test's thread can load the family between them, see #252).
|
||||
for line in &l.lines {
|
||||
assert!((line.bottom - line.top - size * LINE_PITCH).abs() < 1e-6, "{size}: {}", line.bottom - line.top);
|
||||
}
|
||||
let face = &l.runs[0].face;
|
||||
let first = &l.lines[0];
|
||||
assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,7 @@ js-sys = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
||||
//! standards/contributors.md). Nothing is read at run time.
|
||||
//!
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||
//! JSON says the entry was verified (`people_entry`).
|
||||
//!
|
||||
//! A missing or malformed file gives empty tables (or no names) and a warning.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn main() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
||||
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
|
||||
serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
|
||||
})
|
||||
.unwrap_or(Value::Null);
|
||||
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t));
|
||||
if let Err(e) = &r {
|
||||
println!("cargo::warning={}: {e}; {fallback}", path.display());
|
||||
}
|
||||
r.ok()
|
||||
}
|
||||
|
||||
/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal.
|
||||
fn name(v: Option<&str>) -> String {
|
||||
match v.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(x) => format!("Some({x:?})"),
|
||||
None => "None".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn s(v: &Value, k: &str) -> String {
|
||||
format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or(""))
|
||||
}
|
||||
|
||||
fn n(v: &Value, k: &str) -> u64 {
|
||||
v.get(k).and_then(Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn rows(json: &Value, k: &str) -> Vec<Value> {
|
||||
json.get(k).and_then(Value::as_array).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn generate(json: &Value, people: &toml::Table) -> String {
|
||||
let mut o = String::new();
|
||||
let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits"));
|
||||
let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &[");
|
||||
for c in rows(json, "contributors") {
|
||||
let login = c.get("login").and_then(Value::as_str).unwrap_or("");
|
||||
if login.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Names only from a verified, self-chosen people.toml entry.
|
||||
let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true));
|
||||
let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str);
|
||||
let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true);
|
||||
let display = field("display_name")
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None });
|
||||
let _ = writeln!(
|
||||
o,
|
||||
" Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},",
|
||||
name(display),
|
||||
name(field("real_name")),
|
||||
n(&c, "prs"),
|
||||
n(&c, "commits"),
|
||||
n(&c, "lines_added"),
|
||||
n(&c, "lines_deleted"),
|
||||
n(&c, "binary_added"),
|
||||
n(&c, "binary_deleted"),
|
||||
s(&c, "first_commit"),
|
||||
s(&c, "last_commit"),
|
||||
);
|
||||
}
|
||||
let _ = writeln!(o, "];");
|
||||
let _ = writeln!(o, "pub static MODELS: &[Model] = &[");
|
||||
for m in rows(json, "models") {
|
||||
let _ = writeln!(
|
||||
o,
|
||||
" Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},",
|
||||
s(&m, "company"),
|
||||
s(&m, "model"),
|
||||
s(&m, "version"),
|
||||
n(&m, "commits"),
|
||||
n(&m, "lines_added"),
|
||||
n(&m, "lines_deleted"),
|
||||
);
|
||||
}
|
||||
let _ = writeln!(o, "];");
|
||||
o
|
||||
}
|
||||
@@ -100,12 +100,19 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32);
|
||||
let now = ui.input(|i| i.time);
|
||||
let gifs = match app.session.active() {
|
||||
Some(d) if d.selection.target == Target::Slides => app.media.gifs(&d.doc, d.selection.slide, now, &app.session.gif_paused),
|
||||
_ => vec![],
|
||||
};
|
||||
crate::media::gif_repaint(ui.ctx(), &gifs);
|
||||
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
||||
let st = app.session.active();
|
||||
let tex = match st.map(|d| (d.selection.target, d)) {
|
||||
Some((Target::Slides, d)) => {
|
||||
let idx = d.selection.slide;
|
||||
let doc = d.doc.clone();
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, true, 0, app.ui.grayscale)
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
||||
}
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
|
||||
@@ -146,9 +153,37 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
pointer(app, ui, &resp, xf);
|
||||
overlays(app, ui, &painter, xf, &t);
|
||||
media_bar(app, ui, xf);
|
||||
gif_buttons(app, ui, xf, &gifs);
|
||||
context_menu(app, &resp);
|
||||
}
|
||||
|
||||
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
||||
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
if d.selection.text.is_some() || app.session.tool.dragging() {
|
||||
return;
|
||||
}
|
||||
let only = if d.selection.shapes.len() == 1 { d.selection.shapes.first().copied() } else { None };
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut toggle = None;
|
||||
for g in gifs {
|
||||
let r = screen_rect(xf, &g.rect);
|
||||
if only != Some(g.id) && !pointer.is_some_and(|p| r.contains(p)) {
|
||||
continue;
|
||||
}
|
||||
let b = crate::media::gif_button_rect(r);
|
||||
// Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF).
|
||||
let resp = ui.interact(b, ui.id().with(("gif_button", g.id.0)), Sense::click_and_drag());
|
||||
crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered());
|
||||
if resp.clicked() {
|
||||
toggle = Some(g.id);
|
||||
}
|
||||
}
|
||||
if let Some(id) = toggle {
|
||||
let _ = app.run("media.gifPlay", json!({"id": id}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen rect of a slide-space box (rotation ignored).
|
||||
fn screen_rect(xf: Xf, x: &Xfrm) -> Rect {
|
||||
Rect::from_min_max(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h)))
|
||||
@@ -379,14 +414,9 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
&& let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts)
|
||||
{
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let a = x.affine();
|
||||
let rot = if l.rotation != 0.0 {
|
||||
let c = l.inner.center().to_vec2();
|
||||
deckcraft_geom::Affine::translate(c) * deckcraft_geom::Affine::rotate(l.rotation.to_radians()) * deckcraft_geom::Affine::translate(-c)
|
||||
} else {
|
||||
deckcraft_geom::Affine::IDENTITY
|
||||
};
|
||||
let m = a * rot;
|
||||
// Where the text is drawn: flips don't mirror it, vertical text is turned.
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
let (s0, s1) = ts.ordered();
|
||||
for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) {
|
||||
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
||||
@@ -407,9 +437,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
&& let deckcraft_model::ShapeKind::Table(tb) = &sh.kind
|
||||
{
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let y0: f64 = tb.rows.iter().take(r).map(|rr| rr.height).sum();
|
||||
let heights = deckcraft_engine::tools::table_rows(st, tb);
|
||||
let y0: f64 = heights.iter().take(r).sum();
|
||||
let x0: f64 = tb.cols.iter().take(c).sum();
|
||||
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), tb.rows.get(r).map(|rr| rr.height).unwrap_or(0.0));
|
||||
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0));
|
||||
let a = x.affine();
|
||||
let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q));
|
||||
poly(painter, pts, Stroke::new(2.0, t.accent));
|
||||
|
||||
@@ -73,6 +73,7 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
|
||||
"slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"canvasScale": app.canvas_scale,
|
||||
"dialog": app.dialog.as_ref().map(|d| d.id.clone()),
|
||||
"home": app.home_open(),
|
||||
"showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})),
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
|
||||
"status": app.status.as_ref().map(|s| s.0.clone()),
|
||||
@@ -232,7 +233,14 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
"ui.dialog.open" => {
|
||||
let Some(id) = s("id") else { return err("missing `id`") };
|
||||
app.dialog = Some(crate::dialogs::Dialog::new(id));
|
||||
let mut d = crate::dialogs::Dialog::new(id);
|
||||
if let Some(fields) = p.get("fields").and_then(Value::as_object) {
|
||||
for (k, v) in fields {
|
||||
let v = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
d.fields.insert(k.clone(), v);
|
||||
}
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(String::from) },
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
||||
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
||||
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
|
||||
//! read at run time.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use egui::RichText;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Contributor {
|
||||
pub login: &'static str,
|
||||
pub display_name: Option<&'static str>,
|
||||
pub real_name: Option<&'static str>,
|
||||
pub prs: u64,
|
||||
pub commits: u64,
|
||||
pub lines_added: u64,
|
||||
pub lines_deleted: u64,
|
||||
pub binary_added: u64,
|
||||
pub binary_deleted: u64,
|
||||
/// ISO 8601 UTC; empty for someone credited only through a merged PR.
|
||||
pub first_commit: &'static str,
|
||||
pub last_commit: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Model {
|
||||
pub company: &'static str,
|
||||
pub model: &'static str,
|
||||
pub version: &'static str,
|
||||
pub commits: u64,
|
||||
pub lines_added: u64,
|
||||
pub lines_deleted: u64,
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/credits.rs"));
|
||||
|
||||
/// Which name to show. Display and real names fall back to the `@username` when not given.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum NameMode {
|
||||
#[default]
|
||||
Username,
|
||||
DisplayName,
|
||||
RealName,
|
||||
}
|
||||
|
||||
impl NameMode {
|
||||
pub const ALL: [NameMode; 3] = [NameMode::Username, NameMode::DisplayName, NameMode::RealName];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
NameMode::Username => "Username",
|
||||
NameMode::DisplayName => "Display name",
|
||||
NameMode::RealName => "Real name",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SortKey {
|
||||
Name,
|
||||
Prs,
|
||||
Commits,
|
||||
LinesAdded,
|
||||
LinesDeleted,
|
||||
LinesDelta,
|
||||
BinaryAdded,
|
||||
BinaryDeleted,
|
||||
#[default]
|
||||
FirstCommit,
|
||||
LastCommit,
|
||||
}
|
||||
|
||||
impl SortKey {
|
||||
pub const ALL: [SortKey; 10] = [
|
||||
SortKey::Name,
|
||||
SortKey::Prs,
|
||||
SortKey::Commits,
|
||||
SortKey::LinesAdded,
|
||||
SortKey::LinesDeleted,
|
||||
SortKey::LinesDelta,
|
||||
SortKey::BinaryAdded,
|
||||
SortKey::BinaryDeleted,
|
||||
SortKey::FirstCommit,
|
||||
SortKey::LastCommit,
|
||||
];
|
||||
|
||||
/// Menu label and table header.
|
||||
pub fn label(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
SortKey::Name => ("Name (A–Z)", "Name"),
|
||||
SortKey::Prs => ("Merged PRs", "PRs"),
|
||||
SortKey::Commits => ("Commits", "Commits"),
|
||||
SortKey::LinesAdded => ("Lines added", "+LOC"),
|
||||
SortKey::LinesDeleted => ("Lines deleted", "−LOC"),
|
||||
SortKey::LinesDelta => ("Line delta", "ΔLOC"),
|
||||
SortKey::BinaryAdded => ("Binary assets added", "+Bin"),
|
||||
SortKey::BinaryDeleted => ("Binary assets removed", "−Bin"),
|
||||
SortKey::FirstCommit => ("First commit", "First"),
|
||||
SortKey::LastCommit => ("Last commit", "Last"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names and dates read naturally oldest/A first; counts read biggest first.
|
||||
pub fn default_ascending(self) -> bool {
|
||||
matches!(self, SortKey::Name | SortKey::FirstCommit | SortKey::LastCommit)
|
||||
}
|
||||
}
|
||||
|
||||
impl Contributor {
|
||||
pub fn name(&self, mode: NameMode) -> String {
|
||||
let chosen = match mode {
|
||||
NameMode::Username => None,
|
||||
NameMode::DisplayName => self.display_name,
|
||||
NameMode::RealName => self.real_name,
|
||||
};
|
||||
chosen.map_or_else(|| format!("@{}", self.login), str::to_string)
|
||||
}
|
||||
|
||||
pub fn lines_delta(&self) -> i128 {
|
||||
i128::from(self.lines_added) - i128::from(self.lines_deleted)
|
||||
}
|
||||
|
||||
/// One line with everything we know, for tooltips.
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
|
||||
self.login,
|
||||
self.prs,
|
||||
self.commits,
|
||||
group(self.lines_added),
|
||||
group(self.lines_deleted),
|
||||
signed(self.lines_delta()),
|
||||
self.binary_added,
|
||||
self.binary_deleted,
|
||||
day(self.first_commit),
|
||||
day(self.last_commit),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alphabetical key: case-insensitive, ignoring the `@` of usernames.
|
||||
fn name_key(name: &str) -> String {
|
||||
name.trim_start_matches('@').to_lowercase()
|
||||
}
|
||||
|
||||
fn compare(a: &Contributor, b: &Contributor, mode: NameMode, key: SortKey) -> Ordering {
|
||||
match key {
|
||||
SortKey::Name => name_key(&a.name(mode)).cmp(&name_key(&b.name(mode))),
|
||||
SortKey::Prs => a.prs.cmp(&b.prs),
|
||||
SortKey::Commits => a.commits.cmp(&b.commits),
|
||||
SortKey::LinesAdded => a.lines_added.cmp(&b.lines_added),
|
||||
SortKey::LinesDeleted => a.lines_deleted.cmp(&b.lines_deleted),
|
||||
SortKey::LinesDelta => a.lines_delta().cmp(&b.lines_delta()),
|
||||
SortKey::BinaryAdded => a.binary_added.cmp(&b.binary_added),
|
||||
SortKey::BinaryDeleted => a.binary_deleted.cmp(&b.binary_deleted),
|
||||
// A PR-only contributor (no commit date) sorts after everyone with a date.
|
||||
SortKey::FirstCommit => dated(a.first_commit).cmp(&dated(b.first_commit)),
|
||||
SortKey::LastCommit => dated(a.last_commit).cmp(&dated(b.last_commit)),
|
||||
}
|
||||
}
|
||||
|
||||
fn dated(d: &str) -> (bool, &str) {
|
||||
(d.is_empty(), d)
|
||||
}
|
||||
|
||||
/// Contributors in display order. Ties fall back to the name (A–Z) so the order is stable.
|
||||
pub fn sorted(list: &[Contributor], mode: NameMode, key: SortKey, ascending: bool) -> Vec<&Contributor> {
|
||||
let mut v: Vec<&Contributor> = list.iter().collect();
|
||||
v.sort_by(|a, b| {
|
||||
let o = compare(a, b, mode, key);
|
||||
let o = if ascending { o } else { o.reverse() };
|
||||
o.then_with(|| compare(a, b, mode, SortKey::Name)).then_with(|| a.login.cmp(b.login))
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn group(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let mut out = String::with_capacity(s.len() + s.len() / 3);
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn signed(n: i128) -> String {
|
||||
let g = group(u64::try_from(n.unsigned_abs()).unwrap_or(u64::MAX));
|
||||
if n < 0 { format!("−{g}") } else { format!("+{g}") }
|
||||
}
|
||||
|
||||
fn day(iso: &str) -> &str {
|
||||
if iso.is_empty() { "—" } else { iso.get(..10).unwrap_or(iso) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct View {
|
||||
names: NameMode,
|
||||
key: SortKey,
|
||||
ascending: bool,
|
||||
table: bool,
|
||||
}
|
||||
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
View { names: NameMode::Username, key: SortKey::FirstCommit, ascending: true, table: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let id = egui::Id::new("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show");
|
||||
for m in NameMode::ALL {
|
||||
if ui.selectable_label(v.names == m, m.label()).clicked() {
|
||||
v.names = m;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Sort");
|
||||
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
if ui.selectable_label(v.key == k, k.label().0).clicked() {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
|
||||
v.ascending = !v.ascending;
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(!v.table, "Grab bag").clicked() {
|
||||
v.table = false;
|
||||
}
|
||||
if ui.selectable_label(v.table, "Table").clicked() {
|
||||
v.table = true;
|
||||
}
|
||||
});
|
||||
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.label(RichText::new("·").weak());
|
||||
}
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
pub fn models_ui(ui: &mut egui::Ui) {
|
||||
if MODELS.is_empty() {
|
||||
ui.label("No model credits were built into this copy.");
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(
|
||||
login: &'static str,
|
||||
display: Option<&'static str>,
|
||||
real: Option<&'static str>,
|
||||
added: u64,
|
||||
deleted: u64,
|
||||
first: &'static str,
|
||||
) -> Contributor {
|
||||
Contributor {
|
||||
login,
|
||||
display_name: display,
|
||||
real_name: real,
|
||||
prs: 0,
|
||||
commits: 1,
|
||||
lines_added: added,
|
||||
lines_deleted: deleted,
|
||||
binary_added: 0,
|
||||
binary_deleted: 0,
|
||||
first_commit: first,
|
||||
last_commit: first,
|
||||
}
|
||||
}
|
||||
|
||||
fn people() -> [Contributor; 4] {
|
||||
[
|
||||
c("zed", None, None, 5, 50, "2026-01-02T00:00:00Z"),
|
||||
c("Bob", Some("Ann"), None, 10, 0, "2026-01-01T00:00:00Z"),
|
||||
c("alice", None, None, 7, 1, ""),
|
||||
c("carol", None, Some("Dana"), 1, 0, "2026-01-03T00:00:00Z"),
|
||||
]
|
||||
}
|
||||
|
||||
fn logins(v: &[&Contributor]) -> Vec<&'static str> {
|
||||
v.iter().map(|c| c.login).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_fall_back_to_the_username() {
|
||||
let p = c("bob", Some("Bobby"), None, 0, 0, "");
|
||||
assert_eq!(p.name(NameMode::Username), "@bob");
|
||||
assert_eq!(p.name(NameMode::DisplayName), "Bobby");
|
||||
assert_eq!(p.name(NameMode::RealName), "@bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alphabetical_ignores_at_and_case() {
|
||||
let all = people();
|
||||
let v = sorted(&all, NameMode::Username, SortKey::Name, true);
|
||||
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]);
|
||||
let v = sorted(&all, NameMode::DisplayName, SortKey::Name, true);
|
||||
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); // "Ann" sorts with the @usernames
|
||||
let v = sorted(&all, NameMode::RealName, SortKey::Name, false);
|
||||
assert_eq!(logins(&v), ["zed", "carol", "Bob", "alice"]); // "Dana" between bob and zed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_and_dates() {
|
||||
let all = people();
|
||||
let v = sorted(&all, NameMode::Username, SortKey::LinesDelta, false);
|
||||
assert_eq!(logins(&v), ["Bob", "alice", "carol", "zed"]);
|
||||
let v = sorted(&all, NameMode::Username, SortKey::FirstCommit, true);
|
||||
assert_eq!(logins(&v), ["Bob", "zed", "carol", "alice"]); // undated last
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting() {
|
||||
assert_eq!(group(1234567), "1,234,567");
|
||||
assert_eq!(group(12), "12");
|
||||
assert_eq!(signed(-1000), "−1,000");
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
}
|
||||
@@ -693,7 +693,24 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ok || cancel
|
||||
}
|
||||
"about" => {
|
||||
about(app, ui);
|
||||
let tab = d.get("tab", "about");
|
||||
ui.horizontal(|ui| {
|
||||
for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] {
|
||||
if ui.selectable_label(tab == k, l).clicked() {
|
||||
d.fields.insert("tab".into(), k.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
match tab.as_str() {
|
||||
"contributors" | "models" => {
|
||||
// The credits need room for the table; the About tab keeps its compact size.
|
||||
ui.set_min_size(vec2(640.0, 380.0));
|
||||
ui.set_max_height(380.0);
|
||||
if tab == "models" { crate::credits::models_ui(ui) } else { crate::credits::contributors_ui(ui) }
|
||||
}
|
||||
_ => about(app, ui),
|
||||
}
|
||||
let (ok, _) = buttons(ui, "Close");
|
||||
ok
|
||||
}
|
||||
@@ -830,14 +847,23 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
|
||||
paint_logo(ui.painter(), r);
|
||||
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0)));
|
||||
});
|
||||
if app.home_open() {
|
||||
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
if ui.button(format!("← Back to {title}")).on_hover_text("Esc").clicked() {
|
||||
let _ = app.run("app.home", json!({"on": false}));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(40.0);
|
||||
inset_wrapped(ui, |ui| {
|
||||
for th in deckcraft_model::theme::builtin_themes() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
|
||||
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
|
||||
@@ -888,7 +914,50 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Wrapped rows that all start 40 points in from the left, in line with the start screen's
|
||||
/// headings. The inset is a margin on the container: an `add_space` inside `horizontal_wrapped`
|
||||
/// would indent only the first row.
|
||||
fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 40, ..Default::default() }).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(add_contents);
|
||||
});
|
||||
}
|
||||
|
||||
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
|
||||
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
|
||||
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
|
||||
#[test]
|
||||
fn inset_wrapped_indents_every_row() {
|
||||
let left = Cell::new(0.0_f32);
|
||||
let tiles = RefCell::new(Vec::<Rect>::new());
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(500.0, 400.0)).build_ui(|ui| {
|
||||
left.set(ui.max_rect().left());
|
||||
tiles.borrow_mut().clear();
|
||||
inset_wrapped(ui, |ui| {
|
||||
for _ in 0..4 {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
|
||||
tiles.borrow_mut().push(r);
|
||||
}
|
||||
});
|
||||
});
|
||||
harness.run();
|
||||
let tiles = tiles.borrow().clone();
|
||||
let first_top = tiles.first().map_or(0.0, |r| r.top());
|
||||
assert!(tiles.iter().any(|r| r.top() > first_top), "tiles should wrap onto a second row: {tiles:?}");
|
||||
let mut row_top = f32::NEG_INFINITY;
|
||||
for r in &tiles {
|
||||
if r.top() > row_top {
|
||||
row_top = r.top();
|
||||
assert!((r.left() - (left.get() + 40.0)).abs() < 0.5, "row starts at {} instead of {}", r.left(), left.get() + 40.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
|
||||
let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
ui.painter().rect(sw, CornerRadius::same(3), c32(&s.color, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, sc, None) {
|
||||
let mut ng = g.clone();
|
||||
ng.stops[sel].color = with_alpha(c, alpha_of(&s.color));
|
||||
|
||||
+103
-2
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod canvas;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod fillui;
|
||||
pub mod icons;
|
||||
@@ -149,6 +150,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("show.start", "Slide Show", Some("F5"), "{from?: index}"),
|
||||
("show.presenter", "Presenter View", None, "{on?: bool}"),
|
||||
("show.end", "End Show", None, "{}"),
|
||||
("app.home", "Home", None, "{on?}"),
|
||||
("app.about", "About DeckCraft", None, "{}"),
|
||||
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
|
||||
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
|
||||
@@ -189,6 +191,9 @@ pub struct SlideApp {
|
||||
pub palette: Option<(String, usize)>,
|
||||
/// Integrated title bar (macOS traffic lights over our chrome).
|
||||
pub integrated_titlebar: bool,
|
||||
/// The start screen is shown over the open presentations (Home), with the document count
|
||||
/// and active index when it was opened: opening or creating a presentation leaves it.
|
||||
pub home: Option<(usize, Option<usize>)>,
|
||||
pending_urls: Vec<String>,
|
||||
shot_token: u64,
|
||||
queued_shots: Vec<(u64, f64, u32)>,
|
||||
@@ -226,6 +231,7 @@ impl SlideApp {
|
||||
slide_rect: None,
|
||||
palette: None,
|
||||
integrated_titlebar: false,
|
||||
home: None,
|
||||
pending_urls: vec![],
|
||||
shot_token: 0,
|
||||
queued_shots: vec![],
|
||||
@@ -265,6 +271,15 @@ impl SlideApp {
|
||||
r
|
||||
}
|
||||
|
||||
fn docs_key(&self) -> (usize, Option<usize>) {
|
||||
(self.session.documents().len(), self.session.active_index())
|
||||
}
|
||||
|
||||
/// Home is open over a presentation (not just the start screen with nothing open).
|
||||
pub fn home_open(&self) -> bool {
|
||||
self.home.is_some() && self.session.active().is_some()
|
||||
}
|
||||
|
||||
fn flag(&mut self, params: &Value, cur: bool) -> bool {
|
||||
params.get("on").and_then(Value::as_bool).unwrap_or(!cur)
|
||||
}
|
||||
@@ -324,6 +339,10 @@ impl SlideApp {
|
||||
self.show = None;
|
||||
self.media.stop_owned(media::Owner::Show);
|
||||
}
|
||||
"app.home" => {
|
||||
let on = self.flag(p, self.home.is_some());
|
||||
self.home = (on && self.session.active().is_some()).then(|| self.docs_key());
|
||||
}
|
||||
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
|
||||
"app.palette" => self.palette = Some((String::new(), 0)),
|
||||
"app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")),
|
||||
@@ -592,6 +611,22 @@ impl SlideApp {
|
||||
return;
|
||||
}
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
if self.home_open() {
|
||||
// Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the
|
||||
// presentation: typing, pasting or deleting would edit the hidden presentation.
|
||||
for e in events {
|
||||
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e {
|
||||
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
|
||||
if key == egui::Key::Escape {
|
||||
self.home = None;
|
||||
} else if mods.cmd || mods.alt {
|
||||
menus::ui_shortcut(self, key, mods);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.drain_requests();
|
||||
return;
|
||||
}
|
||||
for e in events {
|
||||
match e {
|
||||
egui::Event::Text(t) => {
|
||||
@@ -660,14 +695,17 @@ impl SlideApp {
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
return;
|
||||
}
|
||||
if self.home.is_some_and(|k| k != self.docs_key()) {
|
||||
self.home = None;
|
||||
}
|
||||
self.keyboard(&ctx);
|
||||
ribbon::title_bar(self, ui);
|
||||
if self.session.active().is_some() {
|
||||
if self.session.active().is_some() && !self.home_open() {
|
||||
ribbon::show(self, ui);
|
||||
status::status_bar(self, ui);
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
if self.session.active().is_none() {
|
||||
if self.session.active().is_none() || self.home_open() {
|
||||
dialogs::start_screen(self, ui);
|
||||
return;
|
||||
}
|
||||
@@ -838,3 +876,66 @@ pub fn now_ms() -> f64 {
|
||||
fn js_now() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn title_text(app: &SlideApp) -> String {
|
||||
let d = app.session.active().unwrap();
|
||||
let sh = &d.doc.slides[0].shapes[0];
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// #17: Home created a new blank presentation instead of going to the start screen.
|
||||
#[test]
|
||||
fn home_shows_the_start_screen_over_the_open_presentation() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
// The app keeps requesting repaints, so step frames instead of run().
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().home_open());
|
||||
assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation");
|
||||
|
||||
// Typing on the start screen doesn't reach the presentation behind it; Escape goes back.
|
||||
harness.event(egui::Event::Text("a".into()));
|
||||
harness.key_press(egui::Key::Backspace);
|
||||
harness.run_steps(2);
|
||||
assert_eq!(title_text(harness.state()), "");
|
||||
assert!(!harness.state().session.active().unwrap().is_dirty());
|
||||
harness.key_press(egui::Key::Escape);
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().home_open());
|
||||
|
||||
// Home again toggles; picking a theme on the start screen (a new presentation) leaves it.
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().home_open());
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
assert!(!harness.state().home_open());
|
||||
harness.state_mut().run("app.home", json!({"on": true})).unwrap();
|
||||
harness.state_mut().run("file.new", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().home_open(), "a new presentation leaves Home");
|
||||
assert_eq!(harness.state().session.documents().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_needs_an_open_presentation() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
app.run("app.home", json!({})).unwrap();
|
||||
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Media playback in the UI host: the [`deckcraft_media::Player`] (audio out + clocks), video
|
||||
//! feeds and their textures, the editor's media control bar, and the engine's `media.*` requests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::{MediaStatus, Session};
|
||||
@@ -31,6 +31,18 @@ pub struct MediaHost {
|
||||
videos: HashMap<ShapeId, Video>,
|
||||
/// Probed durations by media bytes (for media imported without a probe).
|
||||
durations: HashMap<usize, f64>,
|
||||
/// Animated GIF clocks: when each started and, while paused, the time it holds.
|
||||
gifs: HashMap<ShapeId, (f64, Option<f64>)>,
|
||||
}
|
||||
|
||||
/// An animated GIF picture on a slide: its box, play time, frame and seconds to the next frame.
|
||||
pub struct Gif {
|
||||
pub id: ShapeId,
|
||||
pub rect: deckcraft_geom::Xfrm,
|
||||
pub t: f64,
|
||||
pub frame: usize,
|
||||
pub next: f64,
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
impl Default for MediaHost {
|
||||
@@ -45,7 +57,30 @@ fn key(id: ShapeId) -> u64 {
|
||||
|
||||
impl MediaHost {
|
||||
pub fn new(out: Option<Box<dyn AudioOut>>) -> MediaHost {
|
||||
MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new() }
|
||||
MediaHost { player: Player::new(out), owners: HashMap::new(), videos: HashMap::new(), durations: HashMap::new(), gifs: HashMap::new() }
|
||||
}
|
||||
|
||||
/// The animated GIF pictures directly on slide `index` at `now`: each plays from when it was
|
||||
/// first shown; a paused one holds its time and resumes from it.
|
||||
pub fn gifs(&mut self, doc: &Presentation, index: usize, now: f64, paused: &HashSet<ShapeId>) -> Vec<Gif> {
|
||||
let Some(slide) = doc.slides.get(index) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
for sh in &slide.shapes {
|
||||
let (ShapeKind::Picture { fill }, Some(rect)) = (&sh.kind, sh.xfrm) else { continue };
|
||||
let Some(item) = doc.media(fill.media).filter(|m| m.data.starts_with(b"GIF8")) else { continue };
|
||||
let p = paused.contains(&sh.id);
|
||||
let c = self.gifs.entry(sh.id).or_insert((now, None));
|
||||
if p && c.1.is_none() {
|
||||
c.1 = Some(now - c.0);
|
||||
} else if !p && let Some(held) = c.1.take() {
|
||||
c.0 = now - held;
|
||||
}
|
||||
let t = c.1.unwrap_or(now - c.0);
|
||||
if let Some((frame, next)) = deckcraft_render::gif_frame(&item.data, &fill.adjust, t) {
|
||||
out.push(Gif { id: sh.id, rect, t, frame, next, paused: p });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace the audio output (the host injects cpal after start-up).
|
||||
@@ -261,6 +296,37 @@ pub fn paint_frame(painter: &egui::Painter, host: &MediaHost, id: ShapeId, rect:
|
||||
true
|
||||
}
|
||||
|
||||
/// Changes whenever a GIF shows another frame (part of the slide texture's cache key).
|
||||
pub fn gif_key(gifs: &[Gif]) -> u64 {
|
||||
gifs.iter().fold(0, |h: u64, g| (h ^ (((g.id.0 as u64) << 32) | g.frame as u64)).wrapping_mul(1099511628211))
|
||||
}
|
||||
|
||||
/// Come back when the next frame of a playing GIF is due.
|
||||
pub fn gif_repaint(ctx: &egui::Context, gifs: &[Gif]) {
|
||||
if let Some(next) = gifs.iter().filter(|g| !g.paused).map(|g| g.next).reduce(f64::min) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis((next * 1000.0).clamp(5.0, 1000.0) as u64));
|
||||
}
|
||||
}
|
||||
|
||||
/// The play/pause button in the bottom-left corner of a GIF's box `r` (PowerPoint puts it there).
|
||||
pub fn gif_button_rect(r: Rect) -> Rect {
|
||||
let s = 24.0f32.min(r.width() - 8.0).min(r.height() - 8.0).max(12.0);
|
||||
Rect::from_min_size(pos2(r.min.x + 6.0, r.max.y - 6.0 - s), vec2(s, s))
|
||||
}
|
||||
|
||||
pub fn paint_gif_button(painter: &egui::Painter, b: Rect, paused: bool, hot: bool) {
|
||||
painter.circle_filled(b.center(), b.width() / 2.0, Color32::from_black_alpha(if hot { 210 } else { 150 }));
|
||||
let r = b.shrink(b.width() * 0.3);
|
||||
let white = Color32::from_gray(235);
|
||||
if paused {
|
||||
icons::paint(painter, r, Icon::Play, white, false);
|
||||
} else {
|
||||
let w = r.width() * 0.3;
|
||||
painter.rect_filled(Rect::from_min_size(r.min, vec2(w, r.height())), CornerRadius::ZERO, white);
|
||||
painter.rect_filled(Rect::from_min_size(pos2(r.max.x - w, r.min.y), vec2(w, r.height())), CornerRadius::ZERO, white);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the control bar asks for.
|
||||
pub enum BarAction {
|
||||
Toggle,
|
||||
|
||||
@@ -210,6 +210,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
("Center", "format.alignCenter"),
|
||||
("Align Right", "format.alignRight"),
|
||||
("Justify", "format.justify"),
|
||||
("-", ""),
|
||||
("Right-to-Left Text Direction", "format.rtl"),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -75,7 +75,7 @@ fn color_button(
|
||||
ui.label(label);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = widgets::drop_button(ui, "Color", vec2(70.0, 22.0), true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, none_label) {
|
||||
let (id, p) = on_pick(c);
|
||||
run(app, &id, p);
|
||||
@@ -391,7 +391,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Color");
|
||||
let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
|
||||
run(app, "design.background", json!({"color": cref_param(&c)}));
|
||||
}
|
||||
@@ -417,7 +417,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
widgets::color_popup(&r).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
|
||||
let (f, b) = if fg { (c, pt.bg.clone()) } else { (pt.fg.clone(), c) };
|
||||
run(app, "design.background", json!({"pattern": {"preset": pt.preset, "fg": cref_param(&f), "bg": cref_param(&b)}}));
|
||||
|
||||
@@ -47,8 +47,8 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
child.spacing_mut().item_spacing.x = 2.0;
|
||||
let has_doc = app.session.active().is_some();
|
||||
let dirty = app.session.active().is_some_and(|d| d.is_dirty());
|
||||
if icon_toggle(&mut child, Icon::Home, "Home", true, false).clicked() {
|
||||
let _ = app.run("file.new", json!({}));
|
||||
if icon_toggle(&mut child, Icon::Home, "Home", true, app.home_open()).clicked() {
|
||||
let _ = app.run("app.home", json!({}));
|
||||
}
|
||||
if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() {
|
||||
app.save();
|
||||
@@ -473,7 +473,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
if m.clicked() {
|
||||
run(app, "format.highlight", json!({"color": "#FFFF00"}));
|
||||
}
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Color")) {
|
||||
run(app, "format.highlight", json!({"color": c.map(|c| serde_json::to_value(cref_param(&c)).unwrap_or_default())}));
|
||||
}
|
||||
@@ -482,7 +482,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
if m.clicked() {
|
||||
run(app, "format.color", json!({"color": "#C00000"}));
|
||||
}
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
|
||||
run(app, "format.color", json!({"color": cref_param(&c)}));
|
||||
}
|
||||
@@ -743,7 +743,7 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "shape.fill", json!({"color": "accent1"}));
|
||||
}
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) {
|
||||
match c {
|
||||
Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})),
|
||||
@@ -757,7 +757,7 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "shape.line", json!({"color": "tx1", "width": 1}));
|
||||
}
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| outline_menu(app, ui, &sc));
|
||||
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
|
||||
}
|
||||
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
@@ -778,6 +778,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
|
||||
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
|
||||
if ui.button(format!("{w} pt")).clicked() {
|
||||
run(app, "shape.line", json!({"width": w}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -793,6 +794,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
|
||||
] {
|
||||
if ui.button(l).clicked() {
|
||||
run(app, "shape.line", json!({"dash": d}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -807,6 +809,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
|
||||
] {
|
||||
if ui.button(l).clicked() {
|
||||
run(app, "shape.line", json!({"head": h, "tail": tl}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1624,6 +1627,19 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
let on = app.session.anim_painter.is_some();
|
||||
let en = enabled(app, "animation.painter");
|
||||
let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on);
|
||||
let r = r.on_hover_text("Animation Painter (double-click to keep it on)");
|
||||
if r.double_clicked() {
|
||||
run(app, "animation.painter", json!({"sticky": true}));
|
||||
} else if r.clicked() {
|
||||
if on {
|
||||
app.session.anim_painter = None;
|
||||
} else {
|
||||
run(app, "animation.painter", json!({}));
|
||||
}
|
||||
}
|
||||
});
|
||||
group(ui, |ui| {
|
||||
rows(ui, |ui| {
|
||||
@@ -1881,7 +1897,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "shape.fill", json!({"color": "accent1"}));
|
||||
}
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) {
|
||||
match c {
|
||||
Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})),
|
||||
@@ -1890,12 +1906,13 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
if ui.button("Gradient…").clicked() {
|
||||
let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
let (_, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| outline_menu(app, ui, &sc));
|
||||
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
|
||||
}
|
||||
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
@@ -1907,7 +1924,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
rows(ui, |ui| {
|
||||
let (_, a) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
|
||||
run(app, "format.color", json!({"color": cref_param(&c)}));
|
||||
}
|
||||
@@ -1915,7 +1932,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
let (_, a) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true);
|
||||
if let Some(a) = a {
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) {
|
||||
run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))}));
|
||||
}
|
||||
@@ -2183,7 +2200,7 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if m.clicked() {
|
||||
run(app, "table.cellFill", json!({"color": "accent1"}));
|
||||
}
|
||||
egui::Popup::menu(&a).show(|ui| {
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) {
|
||||
match c {
|
||||
Some(c) => run(app, "table.cellFill", json!({"color": cref_param(&c)})),
|
||||
|
||||
+165
-7
@@ -1,7 +1,8 @@
|
||||
//! Slide Show: full-screen playback with transitions and animations (`deckcraft-anim`),
|
||||
//! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings.
|
||||
|
||||
use deckcraft_anim::{Layer, ShowAction, ShowState, Source, Timeline};
|
||||
use deckcraft_anim::{Layer, LinkJump, ShowAction, ShowState, Source, Timeline};
|
||||
use deckcraft_model::text::Hyperlink;
|
||||
use deckcraft_model::{Presentation, ShapeId};
|
||||
use deckcraft_render::{ParaState, RenderOpts, ShapeState};
|
||||
use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
|
||||
@@ -37,6 +38,10 @@ pub struct Show {
|
||||
pub ended: bool,
|
||||
/// The slide whose media was last started (auto-play runs once per visit).
|
||||
media_slide: Option<usize>,
|
||||
/// The slide shown before the current one (the Last Slide Viewed action).
|
||||
last_viewed: Option<usize>,
|
||||
/// Click areas of hyperlinks and shape actions on slide `.0`, in slide points.
|
||||
links: Option<(usize, Vec<deckcraft_render::PlacedLink>)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -47,10 +52,15 @@ struct Key {
|
||||
doc: usize,
|
||||
/// Media hidden while not playing.
|
||||
hidden: Vec<ShapeId>,
|
||||
/// The frames animated GIFs show.
|
||||
gif: u64,
|
||||
}
|
||||
|
||||
struct Transition {
|
||||
old: Option<TextureHandle>,
|
||||
/// The slide left behind (Morph matches its shapes) and the Morph frame texture.
|
||||
from: Option<usize>,
|
||||
morph: Option<TextureHandle>,
|
||||
start: f64,
|
||||
kind: String,
|
||||
option: String,
|
||||
@@ -83,6 +93,8 @@ impl Show {
|
||||
last_move: 0.0,
|
||||
ended: false,
|
||||
media_slide: None,
|
||||
last_viewed: None,
|
||||
links: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +130,15 @@ impl Show {
|
||||
.and_then(|s| s.transition.as_ref())
|
||||
.map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0))
|
||||
.unwrap_or(("none".into(), String::new(), 0.0));
|
||||
let old = self.tex.as_ref().map(|(_, t)| t.clone());
|
||||
// Take the old slide's texture rather than cloning its handle: the new slide is
|
||||
// otherwise rendered into the same texture, and the transition blends the new
|
||||
// slide with itself (#33).
|
||||
let (from, old) = self.tex.take().map(|(k, t)| (k.slide, t)).unzip();
|
||||
if from != Some(i) {
|
||||
self.last_viewed = from;
|
||||
}
|
||||
self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {
|
||||
Some(Transition { old, start: now, kind, option, dur })
|
||||
Some(Transition { old, from, morph: None, start: now, kind, option, dur })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -273,9 +291,22 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let media_click = if clicked && !show.pen && !show.ended && show.blank.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
|
||||
if let Some((id, clip)) = media_click {
|
||||
let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx);
|
||||
let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None };
|
||||
let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
|
||||
let link = if on_slide && gif_click.is_none() && media_click.is_none() { link_hit(&ctx, ui, &mut show, &doc) } else { None };
|
||||
if let Some(id) = gif_click {
|
||||
let _ = app.run("media.gifPlay", json!({"id": id}));
|
||||
} else if let Some((id, clip)) = media_click {
|
||||
app.media.toggle(&doc, id, &clip, crate::media::Owner::Show);
|
||||
} else if clicked && let Some(l) = &link {
|
||||
let last = show.last_viewed;
|
||||
match show.state.follow(&doc, &l.action, last) {
|
||||
LinkJump::Show(a) => actions.push(a),
|
||||
LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
LinkJump::Exit => keep = false,
|
||||
LinkJump::None => {}
|
||||
}
|
||||
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) {
|
||||
if show.ended {
|
||||
keep = false;
|
||||
@@ -288,6 +319,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if secondary && !show.ended {
|
||||
actions.push(show.state.prev(&doc));
|
||||
}
|
||||
// Apply input first: a step started by this click must restart the clock before it is read.
|
||||
for a in actions.drain(..) {
|
||||
if !show.apply(a, &doc, now) {
|
||||
keep = false;
|
||||
}
|
||||
}
|
||||
// Step clock.
|
||||
let (step, playing) = (show.state.step, show.state.playing);
|
||||
let t_step = if playing { now - show.step_start } else { 0.0 };
|
||||
@@ -345,7 +382,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
})
|
||||
.map(|m| m.0)
|
||||
.collect();
|
||||
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone() };
|
||||
let gifs = app.media.gifs(&doc, idx, now, &app.session.gif_paused);
|
||||
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
||||
crate::media::gif_repaint(&ctx, &gifs);
|
||||
let gif = crate::media::gif_key(&gifs);
|
||||
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), gif };
|
||||
let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) {
|
||||
show.tex.as_ref().map(|(_, t)| t.clone())
|
||||
} else {
|
||||
@@ -367,6 +408,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
scale: size.0 as f64 / doc.slide_size.width.max(1.0),
|
||||
size: Some(size),
|
||||
state: Some(&f),
|
||||
gif_times: ×,
|
||||
threads,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -383,7 +425,52 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
Some(tex)
|
||||
};
|
||||
let mut drew = false;
|
||||
if let (Some(tr), Some(new)) = (&show.trans, &tex) {
|
||||
let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur);
|
||||
let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None };
|
||||
let morph = match (&show.trans, tl) {
|
||||
(Some(tr), Some(tl)) => {
|
||||
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
|
||||
let hidden_b = |id: ShapeId| {
|
||||
let st = state_for(&tl, id, 0, 0.0);
|
||||
!st.visible || st.opacity <= 0.0 || hidden.contains(&id)
|
||||
};
|
||||
tr.from
|
||||
.and_then(|i| doc.slides.get(i).map(|a| (i, a)))
|
||||
.zip(doc.slides.get(idx))
|
||||
.map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) {
|
||||
let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };
|
||||
let threads =
|
||||
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) };
|
||||
let opts =
|
||||
RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() };
|
||||
let text: Vec<deckcraft_render::TextMorph> = m
|
||||
.text
|
||||
.iter()
|
||||
.map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix })
|
||||
.collect();
|
||||
let paths: Vec<deckcraft_render::PathMorph> = m
|
||||
.paths
|
||||
.iter()
|
||||
.map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix })
|
||||
.collect();
|
||||
let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &paths, &opts);
|
||||
let ci = crate::textures::to_color_image(&img, false);
|
||||
let frame = match tr.morph.take() {
|
||||
Some(mut h) => {
|
||||
h.set(ci, egui::TextureOptions::LINEAR);
|
||||
h
|
||||
}
|
||||
None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR),
|
||||
};
|
||||
painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
tr.morph = Some(frame);
|
||||
drew = true;
|
||||
ctx.request_repaint();
|
||||
} else if let (Some(tr), Some(new)) = (&show.trans, &tex) {
|
||||
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
|
||||
if p < 1.0 {
|
||||
for l in deckcraft_anim::transition_layers(&tr.kind, &tr.option, p) {
|
||||
@@ -420,6 +507,16 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
};
|
||||
crate::media::paint_frame(&painter, &app.media, *id, r);
|
||||
}
|
||||
// Play/pause on the animated GIF under the pointer.
|
||||
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && now - show.last_move < 2.5) {
|
||||
for g in &gifs {
|
||||
let r = on_screen(srect, k, &g.rect);
|
||||
if r.contains(p) {
|
||||
let b = crate::media::gif_button_rect(r);
|
||||
crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if animating {
|
||||
ctx.request_repaint();
|
||||
@@ -440,6 +537,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height()));
|
||||
}
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
} else if link.is_some() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
} else if now - show.last_move > 2.5 && !show.reading {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
@@ -584,6 +683,39 @@ fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> O
|
||||
})
|
||||
}
|
||||
|
||||
/// The link under the pointer: the topmost shape action or text hyperlink on the current slide
|
||||
/// (a text link wins over its shape's action).
|
||||
fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) -> Option<Hyperlink> {
|
||||
let p = ctx.input(|i| i.pointer.hover_pos())?;
|
||||
let full = ui.max_rect();
|
||||
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
|
||||
let srect = fit(avail, doc);
|
||||
let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64;
|
||||
let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k);
|
||||
let i = show.state.slide;
|
||||
if show.links.as_ref().is_none_or(|(s, _)| *s != i) {
|
||||
show.links = Some((i, deckcraft_render::place_slide(doc, i).links));
|
||||
}
|
||||
let (_, links) = show.links.as_ref()?;
|
||||
links.iter().rev().find(|l| l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1).map(|l| l.link.clone())
|
||||
}
|
||||
|
||||
/// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point.
|
||||
fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect {
|
||||
Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k))
|
||||
}
|
||||
|
||||
/// The animated GIF whose play/pause button is under the pointer on the shown slide.
|
||||
fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation, app: &mut SlideApp) -> Option<ShapeId> {
|
||||
let p = ctx.input(|i| i.pointer.interact_pos())?;
|
||||
let full = ui.max_rect();
|
||||
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
|
||||
let srect = fit(avail, doc);
|
||||
let k = srect.width() / doc.slide_size.width.max(1.0) as f32;
|
||||
let gifs = app.media.gifs(doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused);
|
||||
gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id)
|
||||
}
|
||||
|
||||
impl Show {
|
||||
fn reading_bar_hit(&self, ctx: &egui::Context) -> bool {
|
||||
// Clicks on the on-screen controls must not also advance.
|
||||
@@ -686,3 +818,29 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_transition_keeps_the_old_slide_in_its_own_texture() {
|
||||
// #33: the old slide's handle was cloned, so the new slide was rendered into the same
|
||||
// texture and every transition blended the new slide with itself.
|
||||
let mut s = deckcraft_engine::Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let ctx = egui::Context::default();
|
||||
let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR);
|
||||
let mut show = Show::new(&doc, 0, false, false);
|
||||
show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0 }, red.clone()));
|
||||
assert!(show.apply(ShowAction::Slide(1), &doc, 0.0));
|
||||
let tr = show.trans.as_ref().expect("the fade plays");
|
||||
assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id()));
|
||||
assert_eq!(tr.from, Some(0));
|
||||
assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::{Master, Presentation, Slide};
|
||||
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
|
||||
use deckcraft_render::{Image, RenderOpts};
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
|
||||
@@ -60,7 +60,7 @@ fn threads() -> u16 {
|
||||
}
|
||||
|
||||
impl Textures {
|
||||
/// The canvas texture for slide `index` at `size` pixels.
|
||||
/// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames.
|
||||
pub fn slide(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
@@ -70,6 +70,7 @@ impl Textures {
|
||||
edit: bool,
|
||||
extra: u64,
|
||||
grayscale: bool,
|
||||
gif_times: &[(ShapeId, f64)],
|
||||
) -> Option<TextureHandle> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
let master = master_of(p, &slide);
|
||||
@@ -87,7 +88,8 @@ impl Textures {
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, edit, threads: threads(), size: Some(size), ..Default::default() });
|
||||
let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() };
|
||||
let img = deckcraft_render::render_slide(p, index, &opts);
|
||||
self.last_render_ms = crate::now_ms() - t0;
|
||||
let ci = to_color_image(&img, grayscale);
|
||||
let tex = match self.canvas.take() {
|
||||
|
||||
@@ -239,6 +239,13 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool,
|
||||
resp
|
||||
}
|
||||
|
||||
/// The drop-down for a `color_grid`: a menu that stays open when its hex field is clicked
|
||||
/// (egui menus close on any click inside). Picking a colour closes it (`color_grid` calls
|
||||
/// `ui.close()`); other buttons in it must close it themselves.
|
||||
pub fn color_popup(button: &Response) -> egui::Popup<'_> {
|
||||
egui::Popup::menu(button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
}
|
||||
|
||||
/// The colour grid popup body: theme colours with tints/shades, standard colours, extra entries.
|
||||
/// Returns the picked colour (`Some(None)` = "No Fill"/"Automatic" when `none_label` is given).
|
||||
pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) -> Option<Option<ColorRef>> {
|
||||
@@ -440,3 +447,42 @@ pub fn drop_button(ui: &mut Ui, label: &str, size: egui::Vec2, enabled: bool) ->
|
||||
chevron(ui.painter(), egui::pos2(r.rect.max.x - 8.0, r.rect.center().y), 3.0, col);
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use super::*;
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
/// #32: clicking the "More Colors" hex field closed the colour menu before anything could be
|
||||
/// typed. It stays open now; Enter picks the colour and closes it.
|
||||
#[test]
|
||||
fn the_hex_field_keeps_the_color_menu_open() {
|
||||
let picked = RefCell::new(None);
|
||||
let scheme = deckcraft_model::Theme::default().colors;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 500.0)).build_ui(|ui| {
|
||||
theme::install_fonts(ui.ctx());
|
||||
let r = ui.button("Pick");
|
||||
color_popup(&r).show(|ui| {
|
||||
if let Some(c) = color_grid(ui, &scheme, None) {
|
||||
*picked.borrow_mut() = Some(c);
|
||||
}
|
||||
});
|
||||
});
|
||||
harness.get_by_label("Pick").click();
|
||||
harness.run();
|
||||
harness.get_by_role(Role::TextInput).click();
|
||||
harness.run();
|
||||
assert!(harness.query_by_role(Role::TextInput).is_some(), "the menu is still open after clicking the hex field");
|
||||
harness.get_by_role(Role::TextInput).type_text("00FF00");
|
||||
harness.run();
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run();
|
||||
assert_eq!(*picked.borrow(), Some(Some(ColorRef::rgb(Rgba::from_hex("00FF00").unwrap()))));
|
||||
assert!(harness.query_by_role(Role::TextInput).is_none(), "picking a colour closes the menu");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Transitions and animations: parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; effect lists counted from `crates/model/src/anim.rs` against PowerPoint 16.113's galleries) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Transitions and animations are what make a deck a presentation, and the part of PowerPoint people
|
||||
notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md)
|
||||
(Transitions ~80% ready, Animations ~65%); gaps are in [gaps.md](gaps.md).
|
||||
|
||||
## Transitions: 48 of 48
|
||||
|
||||
`crates/model/src/anim.rs` `TRANSITIONS` lists every transition in PowerPoint's gallery, in its
|
||||
three groups, with its Effect Options. All 48 round-trip through PPTX
|
||||
(`every_transition_kind_round_trips`), including the PowerPoint 2010+ `p14`/`p15` ones.
|
||||
|
||||
| Group | PowerPoint | DeckCraft | Fidelity |
|
||||
|---|---|---|---|
|
||||
| Subtle (Morph, Fade, Push, Wipe, Split, Reveal, Cut, Random Bars, Shape, Uncover, Cover, Flash) | 12 | 12 | 2-D, close to PowerPoint |
|
||||
| Exciting (Fall Over, Drape, Curtains, Wind, Prestige, Fracture, Crush, Peel Off, Page Curl, Airplane, Origami, Dissolve, Checkerboard, Blinds, Clock, Ripple, Honeycomb, Glitter, Vortex, Shred, Switch, Flip, Gallery, Cube, Doors, Box, Comb, Zoom, Random) | 29 | 29 | 3-D ones are fake 3-D (trapezoid quads, `crates/anim/src/transition.rs`); particle effects (Vortex, Shred, Fracture, Crush) approximate |
|
||||
| Dynamic Content (Pan, Ferris Wheel, Conveyor, Rotate, Window, Orbit, Fly Through) | 7 | 7 | Same approach |
|
||||
|
||||
| Transition feature | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Effect Options per transition | yes | yes |
|
||||
| Duration, advance on click / after time, Apply To All | yes | yes |
|
||||
| Transition sounds | yes | **no** (the model has the field; no picker, no playback) |
|
||||
| Preview in the editor | yes | yes |
|
||||
| Morph: objects move, resize, recolour | yes | yes |
|
||||
| Morph: Words and Characters | yes | yes (#5) |
|
||||
| Morph: geometry (circle to star) | yes | yes (#27) |
|
||||
| Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink |
|
||||
| Frame-by-frame comparison with PowerPoint | | **not done** |
|
||||
|
||||
## Animations: 64 of ~190 effects
|
||||
|
||||
| Class | PowerPoint (gallery + More Effects) | DeckCraft | Missing (examples) |
|
||||
|---|---:|---:|---|
|
||||
| Entrance | ~50 | 24 | Plus, Wedge, Basic Zoom, Center Revolve, Compress, Stretch, Boomerang, Credits, Curve Up, Drop, Flip, Float, Pinwheel, Spiral In, Whip, Ascend, Descend, Unfold |
|
||||
| Emphasis | ~25 | 17 | Brush Color, Bold Reveal, Contrasting Color, Flicker, Grow With Color, Shimmer, Blink |
|
||||
| Exit | ~50 | 14 | Mirrors of the missing entrance effects, plus Blinds, Box, Checkerboard, Circle, Diamond, Peek Out, Strips as exits |
|
||||
| Motion paths | 64 presets + custom | 6 (Lines, Arcs, Turns, Loops, Shapes-style presets, Custom Path) | Most of Basic (shapes), Lines & Curves and Special presets |
|
||||
| Media (Play, Pause, Stop) | 3 (+ Seek) | 3 | Seek, bookmarks as triggers |
|
||||
|
||||
By how often people use them, the effects present cover nearly all real decks (Appear, Fade, Fly
|
||||
In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85% of use; by count,
|
||||
~34%.
|
||||
|
||||
| Animation feature | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Start On Click / With Previous / After Previous, duration, delay | yes | yes |
|
||||
| Effect Options (direction, sequence by paragraph) | yes | yes |
|
||||
| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; rewind lost on PPTX save (#77) |
|
||||
| By-paragraph text builds, sub-bullets follow parents | yes | yes |
|
||||
| Triggers (on click of a shape) | yes | yes |
|
||||
| Triggers on a media bookmark | yes | no |
|
||||
| Animation Painter | yes | yes (#54) |
|
||||
| Animation Pane: list, reorder, remove | yes | yes |
|
||||
| Animation Pane: advanced timeline (bars you drag) | yes | **no** |
|
||||
| Preview in the editor (current effect, from selected) | yes | partly |
|
||||
| Motion path editing (edit points, reverse, lock) | yes | partly (no edit points) |
|
||||
| Multiple animations on one object | yes | yes |
|
||||
| Round trip of the timing tree | yes | flattened to the pane list on read, rebuilt on write (`every_animation_effect_survives`) |
|
||||
|
||||
## Estimates
|
||||
|
||||
| Work | Hours |
|
||||
|---|---|
|
||||
| Animation Pane timeline, preview from selected, effect dialog depth | 5–8 |
|
||||
| The remaining ~126 effects (most are parameter variations of existing ones) | 3–6 |
|
||||
| Rewind and other effect options on round trip (#77) | 1–2 |
|
||||
| Transition sounds | 1–2 |
|
||||
| Morph across pictures, `!!` names, align once per transition (#67) | 2–4 |
|
||||
| Reference captures from PowerPoint for the 3-D transitions, then tuning | 2–4 |
|
||||
| **Total** | **~14–26** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates |
|
||||
@@ -0,0 +1,77 @@
|
||||
# DeckCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code on main at d556ff9; AGENTS.md §4 keeps the rules) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never
|
||||
break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what
|
||||
exists. 117k lines of Rust in 23 crates, 3 apps and `xtask`; 504 `#[test]` functions
|
||||
(`cargo xtask stats` counts them per crate).
|
||||
|
||||
## Layers
|
||||
|
||||
`cargo xtask layers` enforces that a crate depends only on lower layers (the table in
|
||||
`xtask/src/layers.rs`, which is authoritative; AGENTS.md §4 gives the simplified picture), and that
|
||||
nothing below L6 uses egui, eframe, winit or rfd.
|
||||
|
||||
| Layer | Crates | What they do |
|
||||
|---|---|---|
|
||||
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~150 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
|
||||
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
|
||||
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
|
||||
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
|
||||
| L4 | `pdf` | PDF export through krilla |
|
||||
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
|
||||
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
|
||||
| apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering |
|
||||
|
||||
The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with
|
||||
FilmCraft.
|
||||
|
||||
## Data model
|
||||
|
||||
A `Presentation` holds a theme set, slide masters with their layouts, slides, sections, custom
|
||||
shows, comments, media and document properties. Shapes are a tree (`ShapeKind`: preset or custom
|
||||
geometry, picture, table, chart, group, connector, ink, media, SmartArt-as-group, and `Opaque` for
|
||||
anything read from PPTX that DeckCraft doesn't model: its XML is kept and written back). Text
|
||||
bodies hold paragraphs of runs with DrawingML properties; formatting resolves slide → layout →
|
||||
master → theme at render time. Transitions live on slides; animations are the flat ordered list the
|
||||
Animation Pane shows, rebuilt into PowerPoint's timing tree on save.
|
||||
|
||||
## Commands and frontends
|
||||
|
||||
Every user-visible action is a `CommandSpec` (id, label, ribbon/menu place, shortcut, params doc,
|
||||
`enabled`, `run`) in `crates/engine/src/cmd/*`. The UI, `deckcraft-cli run`, the JSON control
|
||||
channel (`deckcraft --control PORT`, [control-protocol.md](control-protocol.md)) and MCP
|
||||
([mcp.md](mcp.md)) all dispatch through `Session::execute`, so they behave identically.
|
||||
Programmatic calls never open dialogs. Pointer gestures go through `Session::pointer`, keys through
|
||||
`Session::key`.
|
||||
|
||||
## Rendering and the show
|
||||
|
||||
The renderer draws a slide to an RGBA pixmap on the CPU; the UI uploads it as an egui texture.
|
||||
Transitions are layers of textured quads over the old and new slide images (fake 3-D from
|
||||
trapezoids). The show window and the presenter view are egui viewports; animation state per shape
|
||||
comes from `anim::timeline` each frame. PDF export renders each slide at print resolution and
|
||||
overlays an invisible real-text layer with embedded font subsets.
|
||||
|
||||
## File I/O
|
||||
|
||||
- **PPTX** (`crates/pptx`): lenient reader (unknown parts skipped, malformed parts logged and
|
||||
ignored, sizes and depths bounded, zip bombs capped), writer that PowerPoint opens without
|
||||
repair. Unknown elements round-trip through `Opaque` and `raw_ext` fields.
|
||||
- **Native** (`crates/format`): zip with `mimetype`, `presentation.json` and `media/<id>`; readers
|
||||
ignore unknown fields; `.slidecraft` files from before the rename still open.
|
||||
- Formats and their gaps: [file-format-parity.md](file-format-parity.md).
|
||||
|
||||
## Quality gates
|
||||
|
||||
`cargo xtask ci`: rustfmt check, clippy `-D warnings`, tests, layering, asset attribution
|
||||
(`cargo xtask assets`), wasm32 check. CI runs it on Ubuntu for every PR and push to `main`;
|
||||
macOS is built and tested by the release workflow only. Releases (pushes to `release`) build signed
|
||||
macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
|
||||
@@ -0,0 +1,55 @@
|
||||
# Contributors in the About window
|
||||
|
||||
**About ▸ Contributors** credits everyone who contributed to DeckCraft, and **About ▸ Models** credits
|
||||
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
|
||||
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
|
||||
this page is the local copy of the decision.
|
||||
|
||||
## Decision
|
||||
|
||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||
disk or the network at run time; the web build has them too):
|
||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
|
||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||
GitHub profile name), real name (only if they told us), merged PRs, commits, lines added, lines
|
||||
deleted, binary assets added, binary assets removed, first and last commit date.
|
||||
- The list is shown as a **grab bag** (names flowing as text) or a **table**, in the same order. It
|
||||
sorts by name, PRs, commits, lines added, lines deleted, line delta, binary assets added/removed,
|
||||
first or last commit date. The name toggle cycles **Username → Display name → Real name**; a
|
||||
missing name falls back to `@username`. Alphabetical sorting is case-insensitive and ignores the `@`.
|
||||
|
||||
## In DeckCraft
|
||||
|
||||
- The About window is the `"about"` dialog in `crates/ui-egui/src/dialogs.rs`; its `tab` field (`about`, `contributors`, `models`) picks the tab.
|
||||
|
||||
## Credit yourself
|
||||
|
||||
Add your entry to `contributors/people.toml` in a PR you commit yourself (every field optional):
|
||||
|
||||
```toml
|
||||
[people.your-github-username]
|
||||
real_name = "Your Name"
|
||||
display_name = "Nickname"
|
||||
sync_github_name = true # use the name on your public GitHub profile as display name
|
||||
```
|
||||
|
||||
or, with craftrules checked out next to `craft-apps/`:
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
|
||||
```
|
||||
|
||||
Names show after the next refresh: the script checks that each entry was committed by its own
|
||||
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
|
||||
|
||||
## Refreshing (maintainers)
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
|
||||
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
|
||||
```
|
||||
|
||||
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
|
||||
@@ -15,13 +15,13 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) |
|
||||
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
|
||||
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, slide show state, documents, perf (fps, render ms) |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, Home (start screen over the open presentations), slide show state, documents, perf (fps, render ms) |
|
||||
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
||||
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
|
||||
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
|
||||
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id |
|
||||
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the whole window (base64 when no path) |
|
||||
| `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly |
|
||||
| `ui.resize` / `ui.focus` | | Window control |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user