mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662fca6e97 | ||
|
|
f2db8f30e7 | ||
|
|
ce5a88b785 | ||
|
|
3e62501373 | ||
|
|
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 |
@@ -28,9 +28,9 @@ jobs:
|
||||
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 }}
|
||||
@@ -41,11 +41,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: ci-${{ matrix.os }}
|
||||
- name: cargo xtask ci
|
||||
|
||||
@@ -23,34 +23,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
@@ -19,48 +19,5 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/deckcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/deckcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.deckcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.deckcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.deckcraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.deckcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.deckcraft" and m["command"] == "deckcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
+23
-403
@@ -1,16 +1,3 @@
|
||||
# Release pipeline: every push to `release` builds installers for every platform and creates or
|
||||
# updates the draft GitHub Release "DeckCraft v<version>" (tag v<version>). Recipe:
|
||||
# craftrules release/playbook.md.
|
||||
#
|
||||
# The version has one source: `[workspace.package] version` in Cargo.toml (`cargo xtask version`,
|
||||
# `cargo xtask version set X.Y.Z`). The app shows it in About and `--version`; the release name
|
||||
# and tag use it too. workflow_dispatch can override it for a test build (applied to the build).
|
||||
#
|
||||
# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release`
|
||||
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
|
||||
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
|
||||
# other jobs sign nothing and get no secrets, so a workflow_dispatch from another branch is a dry
|
||||
# run: they build, the signed jobs are refused by the environment, and no release is drafted.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -24,400 +11,33 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
DECKCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# Optional font input (storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
# This commit adds fonts/latin-manifest.txt (the Latin presentation fonts).
|
||||
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: no secrets needed, so branch dry runs get past it.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/common.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### DeckCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing (x64 signtool / Azure
|
||||
# Trusted Signing) and WiX (-arch arm64) run as above. package.ps1 checks the PE machine
|
||||
# type and skips the CLI smoke test, since an ARM64 exe can't run on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10).
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/deckcraft$|metainfo|applications'
|
||||
ldd target/release/deckcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm, with the
|
||||
# craft-fonts embedded); no Rust build here. packaging/linux/flatpak/ai.storyteller.deckcraft.yml
|
||||
# stays the from-source manifest for Flathub. 24.04 for a newer flatpak-builder (1.4) than
|
||||
# 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which can fail without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/deckcraft-$DECKCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
VERSION_OVERRIDE: ${{ inputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
envs: DECKCRAFT_VERSION DECKCRAFT_BUILD_DATE DECKCRAFT_BUILD_SHA VERSION_OVERRIDE
|
||||
# Copies the workspace (with dist/release) back; the target dir lives outside it so the
|
||||
# copy stays small.
|
||||
copyback: true
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "*"
|
||||
export CARGO_TARGET_DIR="$HOME/target" CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
if [ -n "$VERSION_OVERRIDE" ]; then cargo xtask version set "$DECKCRAFT_VERSION"; fi
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/deckcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/deckcraft$|/bin/deckcraft-cli$|/share/applications/ai\.storyteller\.deckcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
DECKCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DECKCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$DECKCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Every build is required, so the draft always has the full set the README lists. Needing the
|
||||
# signed jobs also keeps branch dry runs from drafting a release.
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="DeckCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-title: DeckCraft
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -158,8 +158,21 @@ to `main`.
|
||||
## 7. Planning
|
||||
|
||||
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. `ROADMAP.md` (committed)
|
||||
tracks status, milestones, parity and estimates; update it whenever a milestone task lands.
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
|
||||
docs follow craftrules `standards/progress-docs.md`:
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `ROADMAP.md` | Stage, headline numbers, dimensions, features, languages, progress log |
|
||||
| `docs/target-app-parity.md` | The parity assessment against PowerPoint: percentages, hours, calibration |
|
||||
| `docs/gaps.md` | Every known shortfall, ranked: the work list. Pick from the top |
|
||||
| `docs/roadmap.md` | Current focus, the beta plan, milestones |
|
||||
| `docs/parity-checklist.md` | Feature rows scored D/P/M; `cargo xtask parity` regenerates its summary |
|
||||
| `docs/file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`, `animation-parity.md`, `slideshow-parity.md` | Per-dimension and per-feature detail |
|
||||
| `docs/architecture.md` | How DeckCraft is built today |
|
||||
|
||||
When a task lands, re-score its checklist row, delete or update its `gaps.md` entry, add a line to
|
||||
the `ROADMAP.md` progress log, and update each touched doc's status line and revision history.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
|
||||
| `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
+286
-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.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -885,7 +896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -894,7 +905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -903,14 +914,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -919,14 +930,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -948,7 +959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -960,7 +971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -971,7 +982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -979,7 +990,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -988,7 +999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -997,7 +1008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1005,11 +1016,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1019,7 +1030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1037,7 +1048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1048,15 +1059,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
@@ -1071,7 +1082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1087,7 +1098,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -1103,8 +1114,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-geom",
|
||||
@@ -1116,7 +1128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1138,7 +1150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1147,7 +1159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1204,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"
|
||||
@@ -1337,6 +1360,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -1635,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"
|
||||
@@ -2049,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"
|
||||
@@ -2350,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"
|
||||
@@ -3174,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"
|
||||
@@ -4198,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"
|
||||
@@ -4385,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"
|
||||
@@ -4701,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"
|
||||
@@ -5358,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"
|
||||
@@ -5429,7 +5655,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5604,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.4.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"
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
## What works today
|
||||
|
||||
DeckCraft is in early development, heading for its first alpha (see the [roadmap](ROADMAP.md)).
|
||||
DeckCraft is in alpha, heading for beta (see the [roadmap](ROADMAP.md)).
|
||||
Today it can:
|
||||
|
||||
- **Make decks**: slide masters and eleven layouts with placeholders that inherit position and
|
||||
@@ -103,6 +103,9 @@ 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.
|
||||
@@ -151,10 +154,12 @@ deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --con
|
||||
|
||||
## Roadmap
|
||||
|
||||
DeckCraft covers about **79% of PowerPoint's features by breadth** (93% of the core ones) and is
|
||||
roughly **80% of the way to a first alpha**. See [ROADMAP.md](ROADMAP.md) for the alpha checklist,
|
||||
parity estimates and what's next, and [docs/parity.md](docs/parity.md) for the feature-by-feature
|
||||
scorecard.
|
||||
DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core
|
||||
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
|
||||
[docs/gaps.md](docs/gaps.md) for every known shortfall and
|
||||
[docs/parity-checklist.md](docs/parity-checklist.md) for the feature-by-feature checklist.
|
||||
|
||||
## Downloads
|
||||
|
||||
@@ -202,6 +207,8 @@ and web backend defaults are unchanged.
|
||||
| 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 |
|
||||
|
||||
+128
-87
@@ -1,105 +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.
|
||||
Right-to-left and Arabic text: UAX #9 bidi (levels per paragraph, L1/L2 per line, mirrored
|
||||
brackets), contextual HarfRust shaping across style runs, `a:latin`/`a:ea`/`a:cs` slots with
|
||||
per-cluster fallback through theme script fonts, RTL start edge and bullets, `justLow` kashida,
|
||||
bidi caret/hit-testing/selection (`format.rtl`). Arrow keys still move logically (visual
|
||||
movement with caret affinity lands with the keyboard sprint).
|
||||
- Editing: select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; z-order;
|
||||
group/ungroup; align/distribute; Format Painter; Selection pane; clipboard incl. images.
|
||||
- Transitions (fade, push, wipe, split, cover, uncover, zoom, morph…) and animations (entrance,
|
||||
emphasis, exit, motion paths, triggers, by-paragraph builds) on a shared timeline engine.
|
||||
- Slide show: full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen, presenter
|
||||
window, rehearse timings, custom shows, Set Up Show; Reading View.
|
||||
- Views: Normal (thumbnails with sections, slide, notes), Outline, Slide Sorter, Notes Page,
|
||||
Slide Master; zoom; grayscale.
|
||||
- Review: comments, accessibility checker, spelling.
|
||||
- Audio and video: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis and Opus audio; H.264,
|
||||
HEVC, VP9 and AV1 video in MP4/MOV/WebM/MKV (pure-Rust decoders); poster frames, trim, fades,
|
||||
volume, loop, rewind, play across slides, hide during show, full screen; an in-place control bar
|
||||
in the editor and `media.play/pause/stop/seek/info/posterFrame` for agents. WMA/WMV are
|
||||
recognised and embedded but not yet playable.
|
||||
- PDF export: slides, notes pages and handouts (1–9 per page) with a selectable real-text layer, hyperlinks and slide bookmarks (`file.export {format: "pdf", layout}`, File › Export…).
|
||||
- UI: PowerPoint-style ribbon with contextual tabs, ~240 original icons, status bar, panes, command
|
||||
palette, light/dark.
|
||||
- Automation: ~200 commands, every one reachable from `deckcraft-cli`, the app's JSON control
|
||||
channel and the MCP server (headless or connected to the running app).
|
||||
- Web: `apps/deckcraft-web` runs the same UI in the browser (trunk; WebGPU with WebGL2 fallback),
|
||||
opening the sample deck; Open/Insert use the browser file picker, Save/Export download.
|
||||
- Release CI: pushes to `release` build a draft GitHub Release (macOS universal dmg, Windows
|
||||
x64/x86 msi + zip, Linux AppImage/deb/rpm/tar.gz + Flatpak, FreeBSD tar.gz, web zip); signing
|
||||
secrets live in the `release` environment. Version: `cargo xtask version`.
|
||||
|
||||
**Next (in order):** PPTX corpus hardening against real-world decks ·
|
||||
Animation Pane and presenter view polish · vector PDF artwork · Format Shape pane depth (3-D,
|
||||
picture/texture options) · edit points · native macOS menu bar · print · Notes/Handout masters ·
|
||||
equations · SVG pictures · WMA/WMV decoding.
|
||||
|
||||
Milestone details live in `plan/execution-plan.md` (M0–M14, local planning notes).
|
||||
|
||||
## How close to an alpha (estimate, 2026-10-07)
|
||||
|
||||
An **alpha** here means: someone can install a signed build on macOS, Windows or Linux, make a real
|
||||
deck from scratch or from a PowerPoint file, present it with transitions, animations and media,
|
||||
save it back to `.pptx`/PDF without losing work, and hit no crashes on the common paths.
|
||||
|
||||
**DeckCraft is about 80% of the way to that alpha — roughly 30–40 wall-clock hours of a single
|
||||
Claude Opus 5.5 agent** (about 12–18 hours with three agents in parallel).
|
||||
|
||||
| Alpha blocker | State | Estimate |
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| First real release run: signing, notarization, installers verified on each OS | Done for macOS + Windows signing: v0.1.0 published (all platforms built); installers still to be hand-checked on each OS | 2 h |
|
||||
| PPTX fidelity on a corpus of real decks (import, round-trip, opens without repair) | Verified on generated decks only | 8 h |
|
||||
| Presenter view and Animation Pane polish (timeline, reorder, preview) | Working, rough | 6 h |
|
||||
| Soak and fuzz the editor (random command sequences, big decks, undo/redo) for crashes and slowness | Unit tests and guards only | 5 h |
|
||||
| UI fidelity pass on the most-used ribbon groups and dialogs; first-run experience | Mostly there | 6 h |
|
||||
| Mascot app icon (owner), README/site screenshots, user docs | Placeholder icon | 3 h |
|
||||
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
|
||||
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
|
||||
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
| Essentials user | ~62% | estimated: core features × discounts for launch (×0.90), clarity (×0.95) and opening received files (×0.90), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
| Hours to beta | **~120–210** Opus 5.5 agent hours (~40–70 h with 3–4 agents) | estimated, [docs/roadmap.md](docs/roadmap.md) |
|
||||
| Hours to full parity | **~365–650** Opus 5.5 agent hours | estimated, [docs/target-app-parity.md](docs/target-app-parity.md) |
|
||||
|
||||
Already alpha-ready: editing, text, shapes (incl. connectors, freeform, merge), themes and
|
||||
backgrounds, tables, charts, transitions, animations, slide show, audio/video playback, PDF export,
|
||||
AutoRecover, CLI/MCP automation, web build.
|
||||
### Readiness by audience
|
||||
|
||||
## How far from full parity (estimate, 2026-10-07)
|
||||
| Audience | Ready | Opus 5.5 agent hours to ~95% | Work that dominates |
|
||||
|---|---:|---|---|
|
||||
| Full target (ready for real work) | ~55% | 340–600 (~60% parallelizes: ~120–210 h wall clock with 3–4 agents) | Feature depth across all 20 areas, legacy `.ppt`/`.odp` and video export, localization in 11+ languages, GPU raster and hardware decode, AI features |
|
||||
| Mainstream practitioner | ~53% | 150–250 (~50% parallelizes: ~70–120 h with 3 agents) | `.pptx` exchange (real-deck corpus, round-trip losses, EMF/SVG, embedded fonts) 45–75 h; depth in charts, animations, pictures, text, presenting, print/vector PDF 70–110 h; interaction (menu bar, Format Shape pane, font faces, dialogs) 25–40 h; stability 10–20 h |
|
||||
| Essentials user | ~62% | 50–90 (~40% parallelizes: ~30–55 h with 2–3 agents) | Opening received decks (corpus subset, EMF, SVG icons) 25–45 h; Windows launch fallback and Close prompt 6–12 h; font faces, Tab, paste, print, show navigation 12–20 h; start screen, menu bar, palette 6–10 h |
|
||||
|
||||
**Breadth: ~79% weighted** (P0 core 93%, P1 74%, P2 35%) over the 187 features of the PowerPoint catalogue, scored row by
|
||||
row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored
|
||||
done still lack some of PowerPoint's options, dialogs or pixel fidelity, so **overall parity
|
||||
including depth is about 62%**.
|
||||
Calibration is the same as for the full number (see [docs/target-app-parity.md](docs/target-app-parity.md#calibration-how-the-hours-were-derived)): a fix is 0.5–1 h, a checklist row to PowerPoint depth 2–4 h, a subsystem 6–35 h. The sets are nested, so essentials ≤ mainstream ≤ full. The full number is an additive weighted sum over the dimension table; only mainstream and essentials use multiplicative discounts.
|
||||
|
||||
**Remaining work to 100%: about 190 wall-clock hours of a single Claude Opus 5.5 agent** (±30%),
|
||||
or roughly 65–90 hours with four agents in parallel on separate crates:
|
||||
**Why alpha:** all six of PowerPoint's core workflows pass the alpha gate
|
||||
([docs/roadmap.md](docs/roadmap.md#alpha-gate-powerpoints-core-workflows)): build, open and save
|
||||
`.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with
|
||||
transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish
|
||||
have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77),
|
||||
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
|
||||
[docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Work | Estimate |
|
||||
|---|---|
|
||||
| Alpha blockers above | 35 h |
|
||||
| Open P0/P1 rows (vector PDF, print, edit points, SVG, multi-monitor, draw table, SmartArt text pane, Notes/Handout masters…) | 45 h |
|
||||
| Open P2 rows (equations, video export, 3-D, remove background, thesaurus, compare, record show…) | 40 h |
|
||||
| Depth and pixel fidelity of every ribbon group, dialog and pane against PowerPoint | 50 h |
|
||||
| Performance (incremental rendering, GPU raster, streaming media decode) | 20 h |
|
||||
## By dimension
|
||||
|
||||
Basis: in this session the engine, renderer, UI, show engine, PPTX, PDF, media and release
|
||||
pipeline were built in about 30 wall-clock hours with up to three agents in parallel; recent
|
||||
catalogue rows (connectors, freeform, merge shapes, AutoRecover, gradients) took 1–2 hours each.
|
||||
| Dimension | Ready | Hours to full | Doc |
|
||||
|---|---:|---|---|
|
||||
| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | ~8% | 55–90 + native review | [docs/localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance (unmeasured) | ~55% | 10–20 | [docs/hardware-parity.md](docs/hardware-parity.md#performance) |
|
||||
| Stability | ~60% | 10–20 | [docs/gaps.md](docs/gaps.md#stability) |
|
||||
| Platforms | ~80% | 5–10 | [Downloads in README](README.md#downloads) |
|
||||
| Ecosystem (templates; add-ins out of scope) | ~25% | 10–20 | [docs/gaps.md](docs/gaps.md#ecosystem) |
|
||||
| AI features | ~10% | 20–40 | [docs/gaps.md](docs/gaps.md#ai-features) |
|
||||
| Automation ✱ (CLI, control channel, MCP) | ahead | 0 | [docs/mcp.md](docs/mcp.md) |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Breadth (measured) | Ready (est.) | Hours | Doc |
|
||||
|---|---:|---:|---|---|
|
||||
| Application shell | 71% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Slides and sections | 90% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
|
||||
| Views | 90% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
|
||||
| Masters and themes | 85% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
|
||||
| Shapes | 86% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Format (fill, line, effects) | 81% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Arrange | 100% | ~90% | 1–2 | [ui-parity](docs/ui-parity.md) |
|
||||
| Text | 80% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
|
||||
| Tables | 89% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
|
||||
| Charts | 65% | ~45% | 12–20 | [gaps](docs/gaps.md#charts-data-grid-elements-and-the-newer-chart-types) |
|
||||
| SmartArt | 33% | ~25% | 10–16 | [gaps](docs/gaps.md#smartart-is-pre-drawn-shapes-only) |
|
||||
| Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
|
||||
| Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) |
|
||||
| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
|
||||
| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
|
||||
| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
|
||||
| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) |
|
||||
| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Files (commands; formats above) | 69% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
|
||||
|
||||
## Languages
|
||||
|
||||
English only today; no string catalog yet (Ukrainian is in review as PR #34). Detail:
|
||||
[docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | UI strings | Status | Hours to full |
|
||||
|---|---|---:|---|---|
|
||||
| English | en | 100% | full | 0 |
|
||||
| Simplified Chinese | zh-Hans | 0% | none | 6–10 |
|
||||
| Spanish | es | 0% | none | 3–5 |
|
||||
| Hindi | hi | 0% | none | 4–7 |
|
||||
| Arabic | ar | 0% (slide text: bidi and shaping done) | none | 10–16 |
|
||||
| French | fr | 0% | none | 3–5 |
|
||||
| Portuguese | pt | 0% | none | 3–5 |
|
||||
| Indonesian | id | 0% | none | 3–5 |
|
||||
| Japanese | ja | 0% | none | 4–7 |
|
||||
| German | de | 0% | none | 3–5 |
|
||||
| Korean | ko | 0% | none | 4–7 |
|
||||
| Vietnamese | vi | 0% | none | 3–5 |
|
||||
|
||||
Plus 8–14 h once for the catalog infrastructure. Other languages shipped: none. PowerPoint for Mac
|
||||
ships 26.
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
|
||||
image paste (#53), editor soak tests. 10–20 h.
|
||||
2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
|
||||
36–64 h.
|
||||
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
|
||||
4. **Output:** vector PDF, printing. 10–16 h.
|
||||
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
|
||||
presenter view on the right display, native macOS menu bar, Format Shape pane. 33–56 h.
|
||||
|
||||
## Agents: CLI and MCP
|
||||
|
||||
Every command is reachable from `deckcraft-cli` (`run`, `describe`, `commands`, `app` for the running
|
||||
window, `render`, `convert`), from MCP (`deckcraft-cli mcp`, optionally `--connect PORT`), and from the
|
||||
app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-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 }
|
||||
|
||||
@@ -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 |
@@ -29,3 +29,11 @@ real_name = "Alexander Alber"
|
||||
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
|
||||
|
||||
@@ -27,9 +27,9 @@ pub use deckcraft_model::Rgba;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use easing::ease;
|
||||
pub use morph::{MorphFrame, MorphText, morph_frame, 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};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{ColorRef, Fill, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
||||
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
||||
|
||||
use crate::clampf;
|
||||
use crate::easing::smooth;
|
||||
@@ -107,6 +107,19 @@ pub struct MorphFrame {
|
||||
/// 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.
|
||||
@@ -153,9 +166,17 @@ 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, otherwise the new shape fades in under the fading old one on the same moving
|
||||
/// 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.
|
||||
@@ -172,6 +193,7 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
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;
|
||||
@@ -195,18 +217,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let x = match (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb)) {
|
||||
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,
|
||||
};
|
||||
if content(sa) == content(sb) {
|
||||
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))));
|
||||
}
|
||||
push(m, false, 1.0);
|
||||
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);
|
||||
@@ -220,5 +247,6 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
}
|
||||
}
|
||||
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> {
|
||||
|
||||
@@ -569,6 +569,7 @@ fn morph_frame_moves_blends_and_fades() {
|
||||
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));
|
||||
@@ -616,6 +617,43 @@ fn morph_frame_lists_texts_for_words_and_characters() {
|
||||
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() }
|
||||
}
|
||||
@@ -723,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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -425,3 +425,121 @@ fn right_to_left_paragraphs() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1258,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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -143,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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -711,3 +711,58 @@ fn rtl_paragraphs_script_fonts_and_just_low_round_trip() {
|
||||
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)));
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
+91
-34
@@ -9,9 +9,11 @@
|
||||
|
||||
mod chart;
|
||||
mod images;
|
||||
mod morph_path;
|
||||
mod morph_text;
|
||||
mod paint;
|
||||
mod placed;
|
||||
mod ramp;
|
||||
mod table;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -24,12 +26,14 @@ 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;
|
||||
@@ -143,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.
|
||||
@@ -150,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<'_> {
|
||||
@@ -162,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());
|
||||
@@ -194,11 +220,13 @@ impl Fields for SlideFields {
|
||||
|
||||
pub struct Renderer {
|
||||
resources: Resources,
|
||||
/// Outlines morphing in the frame being drawn by `blend`.
|
||||
paths: Vec<PathMorph>,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new() }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,12 +276,22 @@ pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Ima
|
||||
/// 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. `opts.state` applies to
|
||||
/// `shapes` only.
|
||||
pub fn render_blend(pres: &Presentation, old: usize, new: usize, mix: f64, shapes: &[(Shape, bool)], text: &[TextMorph], opts: &RenderOpts) -> Image {
|
||||
/// 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, opts))
|
||||
RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts))
|
||||
})) {
|
||||
Ok(img) => img,
|
||||
Err(_) => {
|
||||
@@ -410,11 +448,7 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
|
||||
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(),
|
||||
}
|
||||
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
|
||||
}
|
||||
|
||||
/// Background plus the master and layout graphics the slide shows.
|
||||
@@ -460,6 +494,7 @@ impl Renderer {
|
||||
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 };
|
||||
@@ -485,6 +520,8 @@ impl Renderer {
|
||||
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 {
|
||||
@@ -502,6 +539,7 @@ impl Renderer {
|
||||
morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view);
|
||||
}
|
||||
}
|
||||
self.paths.clear();
|
||||
self.finish(ctx, w, h)
|
||||
}
|
||||
|
||||
@@ -509,7 +547,7 @@ impl Renderer {
|
||||
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) => {
|
||||
@@ -616,7 +654,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);
|
||||
@@ -641,7 +683,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) => {
|
||||
@@ -649,7 +692,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);
|
||||
@@ -662,13 +705,13 @@ 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -677,8 +720,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);
|
||||
@@ -833,7 +876,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,
|
||||
@@ -857,13 +900,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);
|
||||
@@ -1023,6 +1066,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, &[]);
|
||||
@@ -1031,12 +1093,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 {
|
||||
@@ -1105,7 +1162,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))
|
||||
}
|
||||
@@ -9,7 +9,7 @@ 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};
|
||||
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)]
|
||||
@@ -140,8 +140,11 @@ pub(crate) fn draw(
|
||||
let (ta, loose_a) = tokens(&la, m.chars);
|
||||
let (tb, loose_b) = tokens(&lb, m.chars);
|
||||
let pairs = matches(&ta, &tb);
|
||||
let (ma, mb) = (view * m.from.affine(), view * m.to.affine());
|
||||
let c = mt.as_coeffs();
|
||||
// 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.
|
||||
@@ -195,7 +198,7 @@ pub(crate) fn draw(
|
||||
let b = Affine::translate(-c * ((1.0 - t) / s)) * Affine::scale((1.0 - t) / s + t);
|
||||
(a * ma, b * mb)
|
||||
} else {
|
||||
(mt, mt)
|
||||
(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());
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ 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);
|
||||
@@ -97,12 +96,7 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
|
||||
/// 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 = 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
|
||||
};
|
||||
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 };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+305
-5
@@ -206,14 +206,14 @@ fn blend_ends_match_the_slides_and_mixes_backdrops() {
|
||||
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);
|
||||
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);
|
||||
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 {
|
||||
@@ -258,7 +258,7 @@ fn morph_text_ends_match_the_slides() {
|
||||
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)
|
||||
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));
|
||||
@@ -272,3 +272,303 @@ fn morph_text_ends_match_the_slides() {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +16,7 @@ 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,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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-10
@@ -20,6 +20,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bidi;
|
||||
pub mod datetime;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -129,9 +130,15 @@ pub struct TextLayout {
|
||||
pub boundaries: Vec<Vec<bool>>,
|
||||
}
|
||||
|
||||
/// Values for fields (`slidenum`, `datetime…`, `footer`).
|
||||
/// Values for fields (`slidenum`, `datetime…`, `footer`). `None` keeps the field's saved text.
|
||||
pub trait Fields {
|
||||
fn field(&self, kind: &str) -> Option<String>;
|
||||
/// The value of field `kind` in a run of language `lang` (BCP 47, e.g. `en-US`): dates are
|
||||
/// written the language's way.
|
||||
fn field_in(&self, kind: &str, lang: Option<&str>) -> Option<String> {
|
||||
let _ = lang;
|
||||
self.field(kind)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoFields;
|
||||
@@ -421,7 +428,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
let t: String = match &run.kind {
|
||||
RunKind::Text => run.text.clone(),
|
||||
RunKind::Break => "\u{b}".into(),
|
||||
RunKind::Field { field } => opts.fields.field(field).unwrap_or_else(|| run.text.clone()),
|
||||
RunKind::Field { field } => opts.fields.field_in(field, rp.lang.as_deref()).unwrap_or_else(|| run.text.clone()),
|
||||
RunKind::Math { .. } => run.text.clone(),
|
||||
};
|
||||
let si = styles.len();
|
||||
@@ -559,6 +566,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
cells
|
||||
}
|
||||
|
||||
/// Size of superscript and subscript text relative to the run's size.
|
||||
const SUB_SUPER: f64 = 0.66;
|
||||
|
||||
/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
|
||||
/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -586,7 +596,11 @@ fn shape_item(
|
||||
let st = styles.get(cell.style);
|
||||
let size = st.map(|s| s.size).unwrap_or(18.0);
|
||||
let small = st.is_some_and(|s| s.caps == Caps::Small) && cell.ch.is_lowercase();
|
||||
(if small { size * 0.8 } else { size }) / upem
|
||||
let size = if small { size * 0.8 } else { size };
|
||||
// Superscript and subscript glyphs are drawn at two thirds of the size, so they advance by
|
||||
// two thirds too.
|
||||
let size = if st.is_some_and(|s| s.baseline != 0.0) { size * SUB_SUPER } else { size };
|
||||
size / upem
|
||||
};
|
||||
// Cluster heads in this item, ascending; each cluster spans to the next head.
|
||||
let mut heads: Vec<usize> = glyphs.iter().filter_map(|g| char_at_byte.get(&g.cluster).copied()).filter(|h| range.contains(h)).collect();
|
||||
@@ -672,7 +686,8 @@ fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
};
|
||||
return m.to_string();
|
||||
}
|
||||
c.to_string()
|
||||
// PowerPoint draws only the first character of a longer `buChar` ("••" draws one dot).
|
||||
c.graphemes(true).next().unwrap_or("•").to_string()
|
||||
}
|
||||
|
||||
fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {
|
||||
@@ -687,9 +702,20 @@ fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {
|
||||
(x, out)
|
||||
}
|
||||
|
||||
/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2
|
||||
/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18
|
||||
/// and 40 pt), whatever the font's own ascent and descent.
|
||||
///
|
||||
/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or
|
||||
/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics.
|
||||
/// Keep it a single tunable value, and re-measure on the other platform before changing it.
|
||||
const LINE_PITCH: f64 = 1.2;
|
||||
|
||||
/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where
|
||||
/// the font's Windows ascent and descent put the baseline.
|
||||
fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) {
|
||||
let k = size / face.upem.max(1.0);
|
||||
(face.ascent * k, face.descent * k)
|
||||
let h = size * LINE_PITCH;
|
||||
(h * face.line_split, h * (1.0 - face.line_split))
|
||||
}
|
||||
|
||||
/// Break opportunities: allowed[i] = a line may start at char i.
|
||||
@@ -902,7 +928,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0);
|
||||
let first_x = if p.bullet.is_some() {
|
||||
let bx = (margin + indent).max(0.0);
|
||||
if indent < 0.0 && bx + bullet_w <= margin { margin } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } }
|
||||
// A hanging bullet's text starts at the margin. When the hang reaches left of the box
|
||||
// (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the
|
||||
// hang: the first line's text starts at -indent (later lines stay at the margin).
|
||||
let stop = margin.max(-indent);
|
||||
if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } }
|
||||
} else {
|
||||
(margin + indent).max(0.0)
|
||||
};
|
||||
@@ -1148,7 +1178,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
let Some(st) = p.styles.get(cell.style) else { continue };
|
||||
let Some(&(cx, cx1)) = line.edges.get(k) else { continue };
|
||||
let base_y = line.baseline - st.baseline * st.size;
|
||||
let size = if st.baseline != 0.0 { st.size * 0.66 } else { st.size };
|
||||
let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size };
|
||||
let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size };
|
||||
let key = (cell.style, cell.face);
|
||||
if key != cur_key {
|
||||
@@ -1197,9 +1227,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
}
|
||||
}
|
||||
}
|
||||
let k66 = if st.baseline != 0.0 { 0.66 } else { 1.0 };
|
||||
for (gid, gx, gy) in &cell.glyphs {
|
||||
r.glyphs.push((*gid, cl + kash + gx * k66, base_y + gy));
|
||||
r.glyphs.push((*gid, cl + kash + gx, base_y + gy));
|
||||
r.cells.push((ci, cell.ch));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,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");
|
||||
@@ -395,3 +409,114 @@ fn hostile_bidi_input_never_panics() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -847,6 +847,16 @@ 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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
+102
-2
@@ -150,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+,"), "{}"),
|
||||
@@ -190,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)>,
|
||||
@@ -227,6 +231,7 @@ impl SlideApp {
|
||||
slide_rect: None,
|
||||
palette: None,
|
||||
integrated_titlebar: false,
|
||||
home: None,
|
||||
pending_urls: vec![],
|
||||
shot_token: 0,
|
||||
queued_shots: vec![],
|
||||
@@ -266,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)
|
||||
}
|
||||
@@ -325,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")),
|
||||
@@ -593,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) => {
|
||||
@@ -661,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;
|
||||
}
|
||||
@@ -839,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,
|
||||
|
||||
@@ -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)})),
|
||||
|
||||
+115
-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,6 +52,8 @@ struct Key {
|
||||
doc: usize,
|
||||
/// Media hidden while not playing.
|
||||
hidden: Vec<ShapeId>,
|
||||
/// The frames animated GIFs show.
|
||||
gif: u64,
|
||||
}
|
||||
|
||||
struct Transition {
|
||||
@@ -86,6 +93,8 @@ impl Show {
|
||||
last_move: 0.0,
|
||||
ended: false,
|
||||
media_slide: None,
|
||||
last_viewed: None,
|
||||
links: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +130,13 @@ 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());
|
||||
let from = self.tex.as_ref().map(|(k, _)| k.slide);
|
||||
// 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, from, morph: None, start: now, kind, option, dur })
|
||||
} else {
|
||||
@@ -277,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;
|
||||
@@ -355,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 {
|
||||
@@ -377,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()
|
||||
},
|
||||
@@ -420,7 +452,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
.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 img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &opts);
|
||||
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) => {
|
||||
@@ -470,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();
|
||||
@@ -490,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));
|
||||
@@ -634,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.
|
||||
@@ -736,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 |
|
||||
@@ -15,7 +15,7 @@ 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) |
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# File-format parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
|
||||
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
|
||||
(estimated), **60–110 h** to parity.
|
||||
|
||||
Weights inside this dimension: PPTX 70%, PDF export 10%, image export 5%, legacy binary formats
|
||||
5%, everything else (templates, shows, macros, ODP, video, GIF, outline) 10%.
|
||||
|
||||
## Documents
|
||||
|
||||
From `/Applications/Microsoft PowerPoint.app/Contents/Info.plist` `CFBundleDocumentTypes` (Editor =
|
||||
PowerPoint opens and saves; Viewer = opens only).
|
||||
|
||||
| Format | PowerPoint | DeckCraft read | DeckCraft write | Fidelity / notes | Tested by |
|
||||
|---|---|---|---|---|---|
|
||||
| `.pptx` presentation | Editor | yes | yes | Broad (see below); never measured on real decks | `crates/pptx/tests/roundtrip.rs`, `sample.rs`, `malformed.rs`, `sniff.rs` (30 tests); opened in PowerPoint by hand 2026-10-06 |
|
||||
| `.potx` template | Editor | yes (as a presentation) | no: written with the `.pptx` content type | `file.saveTemplate` is Save As under another name | none |
|
||||
| `.ppsx` slide show | Editor | yes (as a presentation) | no: written as `.pptx` content | Doesn't start in show mode | none |
|
||||
| `.pptm` / `.potm` / `.ppsm` macro-enabled | Editor | no | no | Should open and keep `vbaProject.bin` without running it | none |
|
||||
| `.ppt` / `.pot` / `.pps` 97–2003 binary | Editor | no | no | Owner decision; import only would be enough | none |
|
||||
| `.odp` OpenDocument | Editor | no | no | | none |
|
||||
| `.thmx` Office theme | Viewer | no | no | Browse for themes is a P2 row | none |
|
||||
| `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests |
|
||||
| PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests |
|
||||
| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt`/`.md` outline (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests |
|
||||
| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer | render tests |
|
||||
| TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none |
|
||||
| Animated GIF of the deck | export | n/a | no | | none |
|
||||
| MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none |
|
||||
| Print | yes | n/a | no | PR #66 (browser) open | none |
|
||||
|
||||
## Pictures and media inside decks
|
||||
|
||||
| Kind | PowerPoint | DeckCraft | Notes |
|
||||
|---|---|---|---|
|
||||
| PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 |
|
||||
| SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted |
|
||||
| EMF / WMF | yes | **no** | Common in decks made on Windows: blank picture |
|
||||
| HEIC | yes (macOS) | no | |
|
||||
| Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` |
|
||||
| WMA | yes (Windows) | embedded, not playable | |
|
||||
| Video: H.264, HEVC in MP4/MOV | yes | yes | Pure-Rust, software decode |
|
||||
| Video: VP9, AV1 in WebM/MKV | partly (OS codecs) | yes | Ahead of PowerPoint for Mac |
|
||||
| WMV | yes (Windows) | embedded, not playable | |
|
||||
| Online video (YouTube embeds) | yes | no | Cloud feature |
|
||||
| Embedded OLE objects | yes | kept on round trip (opaque), not insertable or editable | `ShapeKind::Opaque` |
|
||||
| 3-D models (`.glb`) | yes | no | |
|
||||
| Embedded fonts | yes | no | `p:embeddedFontLst` ignored |
|
||||
|
||||
## PPTX coverage (PresentationML / DrawingML)
|
||||
|
||||
What `crates/pptx` reads and writes, from ECMA-376. "Kept" means the XML is preserved untouched for
|
||||
the round trip even though DeckCraft doesn't model it.
|
||||
|
||||
| Part / feature | Read | Write | Notes |
|
||||
|---|---|---|---|
|
||||
| OPC package, content types, relationships | yes | yes | Content types found anywhere in the zip (#19); zip bombs capped; relationship cycles bounded |
|
||||
| Presentation, slide size, sections (p14), custom shows | yes | yes | |
|
||||
| Slide masters, layouts, placeholders and inheritance | yes | yes | |
|
||||
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
|
||||
| Notes slides and notes master | yes | yes | No notes-master editing view |
|
||||
| Handout master | no | no | |
|
||||
| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` |
|
||||
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
|
||||
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
||||
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
|
||||
| 3-D: `scene3d`, `sp3d` | kept | kept | Not rendered |
|
||||
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow` and date fields |
|
||||
| Text warp (`prstTxWarp`) | yes | yes | Not rendered |
|
||||
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
|
||||
| Tables with styles | yes | yes | |
|
||||
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
|
||||
| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | |
|
||||
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save |
|
||||
| Equations (OMML, `a14:m`) | no | no | PR #55 open |
|
||||
| Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) |
|
||||
| Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink |
|
||||
| Transitions incl. p14/p15 (Vortex, Morph…) | all 48 | all 48 | `every_transition_kind_round_trips` |
|
||||
| Animations: timing tree, build paragraphs, triggers, motion paths | yes (flattened to the Animation Pane list) | yes (rebuilt) | `every_animation_effect_survives`; rewind lost (#77) |
|
||||
| Comments: legacy | yes | yes | |
|
||||
| Comments: modern threaded (`p188`) | yes | no (written as legacy) | Replies and resolved state lost (#70) |
|
||||
| Unknown slide-level elements (`graphicFrame` OLE, extensions, `mc:AlternateContent`) | kept | kept | Rendered as a labelled placeholder with the preview picture when present |
|
||||
| Tags, custom XML, VBA | no / no / no | | |
|
||||
| Embedded fonts | no | no | |
|
||||
| Encryption (password to open) | no | no | |
|
||||
|
||||
### What would move the PPTX number
|
||||
|
||||
1. An owned corpus of real-world-shaped decks (corporate templates, SmartArt, chartex charts, EMF,
|
||||
embedded fonts, every transition and animation) in a corpus repo, with import → render → export
|
||||
→ re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint).
|
||||
2. The round-trip losses above (#70, #74, #77).
|
||||
3. EMF/WMF and SVG rendering.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
[target-app-parity.md](target-app-parity.md); the feature-by-feature scores are in
|
||||
[parity-checklist.md](parity-checklist.md). Hours are Opus 5.5 agent wall-clock hours including
|
||||
tests (calibration in [target-app-parity.md](target-app-parity.md#calibration-how-the-hours-were-derived)).
|
||||
`B` marks a gap that blocks **beta**. None blocks **alpha**: all six core workflows pass the
|
||||
[alpha gate](roadmap.md#alpha-gate-powerpoints-core-workflows).
|
||||
|
||||
When a gap closes, delete its entry, update the parity doc it points to, and add a line to the
|
||||
revision history.
|
||||
|
||||
## Ranked: the top 15
|
||||
|
||||
| # | Gap | Category | Beta | Hours | Doc |
|
||||
|---|---|---|:-:|---|---|
|
||||
| 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file |
|
||||
| 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 4 | EMF/WMF and SVG pictures don't render | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) |
|
||||
| 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) |
|
||||
| 8 | Image paste fails on Windows and Linux (#53) | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) |
|
||||
| 9 | Presenter view isn't placed on the second display | Hardware | B | 3–5 | [slideshow-parity.md](slideshow-parity.md) |
|
||||
| 10 | Animation Pane: no timeline, thin preview; 64 of ~190 effects | Feature | B (pane) | 10–16 | [animation-parity.md](animation-parity.md) |
|
||||
| 11 | Charts: data grid editing, elements, `chartex` types | Feature | B (data, elements) | 12–20 | [parity-checklist.md](parity-checklist.md) |
|
||||
| 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) |
|
||||
| 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) |
|
||||
| 14 | PDF export rasterizes slide artwork | File format | B | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 15 | Slides rasterize on the CPU; no performance baseline | Performance / hardware | | 15–30 | [hardware-parity.md](hardware-parity.md) |
|
||||
|
||||
## Stability
|
||||
|
||||
### Close discards an edited presentation without asking (#60)
|
||||
- **Evidence:** issue #60; quitting prompts, `file.close` doesn't. PR #75 is open.
|
||||
- **Impact:** silent data loss on a common path. The worst kind of bug for a presentation app.
|
||||
- **Estimate:** 0.5–1 h (land and test #75). **Beta blocker.**
|
||||
|
||||
### Startup crashes on some Windows GPUs (#40, #59)
|
||||
- **Evidence:** #59 (Intel UHD, access violation in `igvk64.dll` during Vulkan enumeration), #40
|
||||
(WordCraft, GridCraft and DeckCraft all fail on one Windows 11 PC). The DX12 default (#16) fixed
|
||||
AMD's `atio6axx.dll` crash only.
|
||||
- **Impact:** the app never opens for those users.
|
||||
- **Estimate:** 4–8 h (backend probing outside the process or a safe-mode fallback, a software
|
||||
renderer path). Needs a tester with the hardware. **Beta blocker.**
|
||||
|
||||
### No soak or long-session testing
|
||||
- **Evidence:** unit tests (504), PPTX malformed/fuzz tests and the command panic guard exist; no
|
||||
random command sequences over big decks, no memory or undo-depth soak.
|
||||
- **Impact:** unknown crash and slowdown rate in long sessions.
|
||||
- **Estimate:** 4–8 h.
|
||||
|
||||
## File formats
|
||||
|
||||
Detail in [file-format-parity.md](file-format-parity.md).
|
||||
|
||||
### PPTX has never met a corpus of real decks
|
||||
- **Evidence:** every PPTX test (`crates/pptx/tests`, 30 tests) uses decks DeckCraft generates;
|
||||
the 2026-10-07 alpha checklist already listed this as open. No reference renders from PowerPoint
|
||||
exist to compare against.
|
||||
- **Impact:** the main reason to switch is "open my PowerPoint decks". Fidelity on real decks
|
||||
(corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is unknown.
|
||||
- **Estimate:** 20–35 h: build an owned corpus in `storytold/photocraft-corpus` style (craftrules
|
||||
`standards/test-corpora.md`), add import → render → export → re-import checks, have the owner open
|
||||
outputs in PowerPoint, fix what breaks. **Beta blocker.**
|
||||
|
||||
### Round trip loses comment threads, media playback options and animation rewind (#70, #74, #77)
|
||||
- **Evidence:** issues filed 2026-10-10 by an automated reviewer; the writer emits legacy comments
|
||||
only (no `p188` modern comments with replies and resolved state, though the reader understands
|
||||
them); media playback options and the animation rewind flag aren't written back.
|
||||
- **Impact:** a deck opened and saved silently loses review state and show behaviour.
|
||||
- **Estimate:** 3–6 h. **Beta blocker.**
|
||||
|
||||
### EMF/WMF and SVG pictures don't render
|
||||
- **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF,
|
||||
TIFF, BMP); nothing reads EMF, WMF or SVG. Decks from Windows often hold EMF (pasted charts,
|
||||
Visio, logos); PowerPoint's Insert Icons stores SVG with a PNG fallback.
|
||||
- **Impact:** blank pictures in real decks; SVG icons show only their raster fallback, and SVG files
|
||||
can't be inserted.
|
||||
- **Estimate:** 8–14 h (an SVG renderer through `resvg`-style crates, an EMF/WMF record player for
|
||||
the common records). **Beta blocker** for EMF.
|
||||
|
||||
### Template, slide-show and macro variants are saved as plain `.pptx`
|
||||
- **Evidence:** `crates/pptx/src/write/mod.rs` always writes the
|
||||
`presentationml.presentation.main+xml` content type; `.potx`/`.ppsx` open, `.pptm`/`.potm`/`.ppsm`
|
||||
aren't recognised.
|
||||
- **Impact:** a template saved as `.potx` isn't a template to PowerPoint; macro decks can't be
|
||||
opened even to view.
|
||||
- **Estimate:** 2–4 h (content types; keep `vbaProject.bin` untouched without running it).
|
||||
|
||||
### No legacy `.ppt`/`.pot`/`.pps` import
|
||||
- **Evidence:** PowerPoint for Mac still opens and saves the 97–2003 binary formats
|
||||
(`CFBundleDocumentTypes`); DeckCraft doesn't read them.
|
||||
- **Impact:** archives of older decks don't open.
|
||||
- **Estimate:** 20–35 h (import only, from the public [MS-PPT] spec). Owner decision whether it's
|
||||
worth it.
|
||||
|
||||
### No OpenDocument `.odp`
|
||||
- **Evidence:** PowerPoint opens and saves `.odp`.
|
||||
- **Impact:** LibreOffice users and public-sector exchange.
|
||||
- **Estimate:** 12–20 h (read and write the common subset).
|
||||
|
||||
### PDF export rasterizes slide artwork
|
||||
- **Evidence:** `crates/pdf` places each slide as an image under a selectable text layer, links and
|
||||
bookmarks.
|
||||
- **Impact:** large PDFs; blurry when zoomed or printed.
|
||||
- **Estimate:** 5–8 h (vector paths, fills and text through the PDF writer). **Beta blocker.**
|
||||
|
||||
### No printing
|
||||
- **Evidence:** checklist row "Print" is missing; PR #66 (browser print) is open.
|
||||
- **Impact:** handouts and speaker notes are printed in many workplaces.
|
||||
- **Estimate:** 5–8 h (print through the PDF path and each OS's print dialog). **Beta blocker.**
|
||||
|
||||
### No video or animated GIF export
|
||||
- **Evidence:** checklist row missing; PowerPoint exports MP4/MOV and animated GIF.
|
||||
- **Impact:** sharing a talk as a video is a common request.
|
||||
- **Estimate:** 10–16 h (render the timeline frame by frame; an H.264 encoder is the hard part,
|
||||
FilmCraft has one to copy).
|
||||
|
||||
### Embedded fonts aren't read or written
|
||||
- **Evidence:** no `p:embeddedFontLst` handling in `crates/pptx`.
|
||||
- **Impact:** decks that rely on embedded fonts reflow.
|
||||
- **Estimate:** 3–5 h.
|
||||
|
||||
## Features
|
||||
|
||||
### Font weights beyond Regular, Bold and Italic (#69)
|
||||
- **Evidence:** #69; the font database maps a family to four faces.
|
||||
- **Impact:** Light, Medium and Semibold headings (common in modern templates) render wrong.
|
||||
- **Estimate:** 3–5 h. **Beta blocker.**
|
||||
|
||||
### Image paste fails on Windows and Linux (#53)
|
||||
- **Evidence:** #53 (v0.3.0, Fedora AppImage and Windows).
|
||||
- **Impact:** copy-paste of screenshots is a core workflow.
|
||||
- **Estimate:** 2–4 h. **Beta blocker.**
|
||||
|
||||
### Tab inside a text box ends text entry (#78)
|
||||
- **Evidence:** #78.
|
||||
- **Impact:** tab stops and indented lists can't be typed.
|
||||
- **Estimate:** 1–2 h.
|
||||
|
||||
### Charts: data grid, elements and the newer chart types
|
||||
- **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other
|
||||
chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box &
|
||||
whisker) aren't read.
|
||||
- **Impact:** most business decks have charts; editing their data is routine.
|
||||
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).
|
||||
|
||||
### SmartArt is pre-drawn shapes only
|
||||
- **Evidence:** `crates/pptx/src/read/shapes.rs` reads SmartArt's drawing part as a group; no text
|
||||
pane, no layout switching, the diagram data isn't regenerated.
|
||||
- **Impact:** editing a SmartArt diagram from PowerPoint breaks it.
|
||||
- **Estimate:** 10–16 h.
|
||||
|
||||
### Animation Pane, effect count and preview
|
||||
Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**.
|
||||
|
||||
### Notes and Handout masters
|
||||
- **Evidence:** checklist row missing; PPTX writes a notes master but there's no view to edit it,
|
||||
and no handout master at all.
|
||||
- **Estimate:** 4–6 h.
|
||||
|
||||
### Edit points, text warp, 3-D
|
||||
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms", "Bevel and 3-D
|
||||
rotation" (missing; `scene3d`/`sp3d` kept as raw XML on round trip).
|
||||
- **Estimate:** 4–6 h (edit points), 6–10 h (warp), 8–12 h (3-D render approximation).
|
||||
|
||||
### Proofing: spelling, thesaurus, AutoCorrect
|
||||
- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings
|
||||
(`crates/engine/src/cmd/review.rs`); no dictionaries, no thesaurus, no AutoCorrect.
|
||||
- **Estimate:** 8–14 h (Hunspell-format dictionaries under an open licence per language).
|
||||
|
||||
### Equations
|
||||
- **Evidence:** checklist row missing; PR #55 (OMML editor, +10.5k lines) is open.
|
||||
- **Estimate:** 3–6 h to review, land and round-trip.
|
||||
|
||||
### Smaller missing rows
|
||||
Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1–2 h), transition
|
||||
sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical
|
||||
matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide
|
||||
show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile
|
||||
encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set).
|
||||
|
||||
## UI/UX
|
||||
|
||||
Detail in [ui-parity.md](ui-parity.md).
|
||||
|
||||
- **No native macOS menu bar.** `menus::menu_tree()` exists but nothing installs it. 3–5 h. **Beta
|
||||
blocker.**
|
||||
- **Format Shape pane thinner than PowerPoint's** (Fill/Line/Effects/Size/Text Box pages, picture
|
||||
and texture options, 3-D). 6–10 h. **Beta blocker.**
|
||||
- **First-run and start screen** (#17, #76: missing File overlay, broken layout; recent files and
|
||||
templates thin). 3–5 h.
|
||||
- **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
|
||||
2–4 h.
|
||||
- **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules.
|
||||
- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog,
|
||||
Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h.
|
||||
- **Command palette defaults to an Export entry that fails** (#79). 0.5 h.
|
||||
|
||||
## Hardware
|
||||
|
||||
Detail in [hardware-parity.md](hardware-parity.md).
|
||||
|
||||
- **Presenter view isn't placed on the second display**; the show doesn't pick the projector. 3–5 h.
|
||||
**Beta blocker.**
|
||||
- **CPU slide rasterization** (`vello_cpu`); PowerPoint renders on the GPU. 12–20 h (vello hybrid
|
||||
or wgpu path).
|
||||
- **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy
|
||||
FilmCraft's hardware decode).
|
||||
- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h.
|
||||
|
||||
## Localization
|
||||
|
||||
Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships
|
||||
26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the
|
||||
infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).
|
||||
- **No IME composition in text editing** (CJK input). 3–6 h.
|
||||
- **UI isn't mirrored for right-to-left languages.** 6–10 h.
|
||||
- **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h.
|
||||
|
||||
## Performance
|
||||
|
||||
- **No baseline.** Nothing measures open time, render time per slide, show frame rate or memory
|
||||
against PowerPoint. 4–8 h for a benchmark suite in `xtask`.
|
||||
- **Morph aligns outlines every frame** (#67, PR open). 1 h.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
- **8 original themes, no template gallery.** PowerPoint offers dozens of designs online and
|
||||
offline. 8–14 h for a larger original set (asset policy: our own designs only).
|
||||
- **No add-ins or VBA.** Out of scope by policy; MCP/CLI automation is the alternative.
|
||||
|
||||
## AI features
|
||||
|
||||
- **No Designer, Copilot, Speaker Coach, live subtitles or Translate.** All are Microsoft cloud
|
||||
services; an offline rule-based Designer is in the catalogue (P2). Local models (speech-to-text
|
||||
for subtitles, translation) need an owner decision on licensed models. 20–40 h.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
|
||||
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
|
||||
@@ -0,0 +1,74 @@
|
||||
# Hardware parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
||||
|
||||
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
|
||||
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
|
||||
because it mostly follows from hardware use.
|
||||
|
||||
## Graphics and rendering
|
||||
|
||||
| Feature | PowerPoint | DeckCraft macOS | DeckCraft Windows | DeckCraft Linux/BSD | DeckCraft web |
|
||||
|---|---|---|---|---|---|
|
||||
| Window compositing on the GPU | Metal / DirectX | wgpu (Metal) | wgpu, DX12 only by default (#16) | wgpu (Vulkan, GL) | WebGPU, WebGL2 fallback |
|
||||
| Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) |
|
||||
| Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same |
|
||||
| High-DPI / Retina | yes | yes | yes | yes | yes |
|
||||
| Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no |
|
||||
| Startup on all GPUs | yes | yes | **no**: crashes in some Intel/AMD drivers (#40, #59) | not reported | n/a |
|
||||
| Software fallback when no GPU | yes (WARP on Windows) | no | no | no | no |
|
||||
|
||||
## Media
|
||||
|
||||
| Feature | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Video decode | Hardware (AVFoundation/VideoToolbox, Media Foundation) | Software only: pure-Rust H.264, HEVC, VP9, AV1 (`crates/h264`, `hevc`, `vp9`, `av1`) |
|
||||
| 4K video in the show | smooth on hardware decode | unmeasured; likely CPU-bound |
|
||||
| Audio output | Core Audio / WASAPI | `cpal` (Core Audio, WASAPI, ALSA); browser Web Audio |
|
||||
| Microphone (record audio, narration) | yes | no |
|
||||
| Camera (Cameo, record with camera) | yes | no |
|
||||
| Screen recording | yes | no |
|
||||
|
||||
## Displays
|
||||
|
||||
| Feature | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Detect the projector, show on it, presenter view on the laptop | yes | **no**: the show goes full screen on the current display; presenter view opens as a normal window (egui viewport) |
|
||||
| Swap displays, choose the monitor in Set Up Show | yes | no |
|
||||
| Mirroring-aware behaviour | yes | no |
|
||||
|
||||
## Input devices
|
||||
|
||||
| Feature | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes |
|
||||
| Mouse and trackpad scroll | yes | yes |
|
||||
| Pinch to zoom, two-finger gestures | yes | no |
|
||||
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
|
||||
| Touch screens (Windows) | yes | basic pointer only |
|
||||
| IME input methods for CJK text | yes | no (see [localization-parity.md](localization-parity.md)) |
|
||||
| Dictation | yes (macOS) | no |
|
||||
|
||||
## Performance
|
||||
|
||||
Nothing is measured against PowerPoint yet: no benchmark of open time, render time per slide,
|
||||
show frame rate or memory exists in `xtask`. Known: slides rasterize on the CPU, Morph aligns
|
||||
outlines every frame (#67, fix open), and the decoders run in software.
|
||||
|
||||
## Estimates
|
||||
|
||||
| Work | Hours |
|
||||
|---|---|
|
||||
| Presenter view on the right display, choose monitor, swap | 3–5 |
|
||||
| GPU slide rasterization (vello's GPU/hybrid path) | 12–20 |
|
||||
| Hardware video decode (copy FilmCraft's VideoToolbox / Media Foundation / VA-API paths) | 8–14 |
|
||||
| Windows GPU startup robustness: backend probing, software fallback (#40, #59) | 4–8 (counted under Stability) |
|
||||
| Pen pressure, pinch zoom | 3–6 |
|
||||
| Benchmark suite (`cargo xtask bench`) and a first performance pass | 6–12 (Performance dimension) |
|
||||
| **Hardware total** | **~26–45** |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
|
||||
@@ -0,0 +1,59 @@
|
||||
# Localization parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
|
||||
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
|
||||
(Ukrainian, open). Dimension score: **~8% ready** (estimated: English complete, slide-text script
|
||||
support partly there), **55–90 h** to parity plus native-speaker review.
|
||||
|
||||
## How the numbers are measured
|
||||
|
||||
- **UI strings:** ~1,050 user-visible string literals (measured 2026-10-10: distinct
|
||||
capitalized literals in `crates/ui-egui/src`, `crates/engine/src/cmd` and `crates/model/src`,
|
||||
including the 226 command labels). A catalog will refine the count; until then percentages are
|
||||
against this figure.
|
||||
- **Script support** is about slide text (what users type and import), separately from the UI:
|
||||
bidi and shaping live in `crates/text` (UAX #9, HarfRust, UAX #14 line breaking).
|
||||
- **PowerPoint's languages:** `Contents/Resources/*.lproj` in PowerPoint 16.113.4: ar, cs, da, de,
|
||||
el, en, en_GB, es, es_MX, fi, fr, fr_CA, he, hu, id, it, ja, ko, nl, no, pl, pt (Brazil), pt_PT,
|
||||
ru, sk, sv, th, tr, zh_CN, zh_TW (26 languages). PowerPoint for **Mac** has no Hindi or Vietnamese
|
||||
UI; PowerPoint for Windows has both.
|
||||
|
||||
## The twelve key languages
|
||||
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; **no IME composition**; no true vertical layout | no | none | yes | 6–10 (incl. IME) |
|
||||
| Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 |
|
||||
| Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) |
|
||||
| French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 |
|
||||
| Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 |
|
||||
| Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
||||
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; no IME composition; vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
|
||||
| German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
||||
| Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | no | none | yes | 4–7 |
|
||||
| Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 |
|
||||
|
||||
Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
|
||||
than the twelve above: Czech, Danish, Greek, Finnish, Hebrew, Hungarian, Italian, Dutch, Norwegian,
|
||||
Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese.
|
||||
|
||||
Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a
|
||||
sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows
|
||||
the system (#15), plural and number formatting, and an `xtask` check that every key has a
|
||||
translation. IME composition (3–6 h) is counted once, under Simplified Chinese.
|
||||
|
||||
## Proofing languages
|
||||
|
||||
PowerPoint checks spelling in dozens of languages and lets each run carry a proofing language.
|
||||
DeckCraft keeps `a:rPr/@lang` on round trip but its spelling check is a small English list of
|
||||
common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#proofing-spelling-thesaurus-autocorrect).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
|
||||
@@ -1,42 +1,34 @@
|
||||
# Feature parity with PowerPoint
|
||||
# PowerPoint feature checklist
|
||||
|
||||
The summary is generated by `cargo xtask parity` from the table at the bottom of this file (edit a
|
||||
row, re-run). Rows come from the black-box feature catalogue of PowerPoint for Mac (menus, ribbon
|
||||
tabs, dialogs and panes observed in the running app — no Microsoft code, documentation assets or
|
||||
images were copied).
|
||||
> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Each row is scored **D** (done), **P** (partial: works but lacks options, UI or fidelity) or **M**
|
||||
(missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out of scope: Microsoft
|
||||
365 cloud services (co-authoring through OneDrive, Copilot, stock images, Designer's online service,
|
||||
Live Presentations), add-ins and VBA macros.
|
||||
|
||||
<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 79%** over 187 features.
|
||||
The summary between `<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 79%** over 191 features.
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 62 | 10 | 0 | 93% |
|
||||
| P1 | 65 | 37 | 22 | 6 | 74% |
|
||||
| P2 | 50 | 12 | 11 | 27 | 35% |
|
||||
| P0 | 72 | 60 | 12 | 0 | 92% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 13 | 28 | 36% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell | 71% |
|
||||
| Files | 71% |
|
||||
| Slides | 95% |
|
||||
| Slides | 90% |
|
||||
| Views | 90% |
|
||||
| Masters & themes | 85% |
|
||||
| Shapes | 86% |
|
||||
| Format | 81% |
|
||||
| Arrange | 100% |
|
||||
| Text | 83% |
|
||||
| Text | 80% |
|
||||
| Tables | 89% |
|
||||
| Charts | 65% |
|
||||
| SmartArt | 33% |
|
||||
| Pictures | 58% |
|
||||
| Media | 68% |
|
||||
| Transitions | 83% |
|
||||
| Animations | 74% |
|
||||
| Pictures | 48% |
|
||||
| Media | 67% |
|
||||
| Transitions | 92% |
|
||||
| Animations | 79% |
|
||||
| Slide show | 66% |
|
||||
| Review | 60% |
|
||||
| Draw | 33% |
|
||||
@@ -44,7 +36,7 @@ Live Presentations), add-ins and VBA macros.
|
||||
| Automation (✱) | 100% |
|
||||
| Platforms (✱) | 67% |
|
||||
|
||||
Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Edit chart data, Crop (and crop to shape, aspect ratio), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
|
||||
Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows and Linux, #53), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
|
||||
|
||||
## Rows
|
||||
|
||||
@@ -65,7 +57,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
|
||||
| Files | Native file format (open/save) | P0 | D |
|
||||
| Files | Open PowerPoint .pptx | P0 | D |
|
||||
| Files | Save as PowerPoint .pptx | P0 | D |
|
||||
| Files | Open/save .potx/.ppsx/.pptm variants | P2 | M |
|
||||
| Files | Open/save .potx/.ppsx/.pptm variants (.potx/.ppsx open; saved with the .pptx content type; no .pptm) | P2 | P |
|
||||
| Files | Legacy .ppt (binary) import | P2 | M |
|
||||
| Files | Save as template | P1 | D |
|
||||
| Files | Autosave and crash recovery | P0 | D |
|
||||
@@ -78,6 +70,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
|
||||
| Files | Password protection | P2 | M |
|
||||
| Files | Reuse slides from another file | P1 | M |
|
||||
| Files | Drag and drop files onto the window | P1 | D |
|
||||
| Files | OpenDocument .odp open/save | P2 | M |
|
||||
| Slides | New slide by layout | P0 | D |
|
||||
| Slides | Duplicate, delete, hide slides | P0 | D |
|
||||
| Slides | Reorder slides (thumbnails, sorter) | P0 | D |
|
||||
@@ -86,6 +79,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
|
||||
| Slides | Reset slide | P1 | D |
|
||||
| Slides | Slides from outline | P2 | D |
|
||||
| Slides | Copy/paste slides between decks | P1 | P |
|
||||
| Slides | Zoom (summary, section and slide zoom) | P2 | M |
|
||||
| Views | Normal view (thumbnails, slide, notes) | P0 | D |
|
||||
| Views | Outline view | P1 | D |
|
||||
| Views | Slide Sorter | P0 | D |
|
||||
@@ -145,7 +139,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
|
||||
| Arrange | Lock shapes | P2 | D |
|
||||
| Arrange | Alt text | P1 | D |
|
||||
| Text | In-place text editing (caret, selection, mouse, keyboard) | P0 | D |
|
||||
| Text | Font, size, bold, italic, underline, strikethrough | P0 | D |
|
||||
| Text | Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69) | P0 | P |
|
||||
| Text | Font colour, highlight | P0 | D |
|
||||
| Text | Superscript, subscript, caps, character spacing | P1 | D |
|
||||
| Text | Change case, grow/shrink font, clear formatting | P1 | D |
|
||||
@@ -193,25 +187,27 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
|
||||
| Pictures | Picture styles and borders | P1 | P |
|
||||
| Pictures | Change / reset picture | P1 | D |
|
||||
| Pictures | Compress pictures | P2 | M |
|
||||
| Pictures | Paste image from clipboard | P0 | D |
|
||||
| Pictures | Paste image from clipboard (fails on Windows and Linux, #53) | P0 | P |
|
||||
| Pictures | Insert screenshot | P2 | M |
|
||||
| Media | Insert audio (any common format) | P0 | D |
|
||||
| Media | Insert video | P1 | D |
|
||||
| Media | Audio/video playback in the show (WMA/WMV not decodable yet) | P0 | P |
|
||||
| Media | Trim, fade in/out, volume, loop, play across slides | P2 | D |
|
||||
| Media | Record audio / screen recording | P2 | M |
|
||||
| Media | Icons, 3-D models, stock content | P2 | M |
|
||||
| Media | Embedded objects (OLE): insert; foreign objects kept on round trip | P2 | P |
|
||||
| Transitions | Transition gallery (fade, push, wipe, split, cover, uncover, zoom, morph…) | P0 | D |
|
||||
| Transitions | Effect options and duration | P0 | D |
|
||||
| Transitions | Advance on click / after time, apply to all | P0 | D |
|
||||
| Transitions | Transition sounds | P2 | M |
|
||||
| Transitions | Morph | P1 | P |
|
||||
| Transitions | Morph | P1 | D |
|
||||
| Animations | Entrance, emphasis, exit effects | P0 | D |
|
||||
| Animations | Motion paths | P1 | P |
|
||||
| Animations | Effect options, start (on click/with/after), duration, delay | P0 | D |
|
||||
| Animations | Animation pane (reorder, timing) | P0 | P |
|
||||
| Animations | By-paragraph text builds | P1 | D |
|
||||
| Animations | Triggers | P2 | D |
|
||||
| Animations | Animation Painter | P2 | M |
|
||||
| Animations | Animation Painter | P2 | D |
|
||||
| Animations | Preview in editor | P1 | P |
|
||||
| Slide show | Full-screen show from start / current | P0 | D |
|
||||
| Slide show | Navigation (keys, click, go to slide, blank screen) | P0 | D |
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# DeckCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Forward-looking: where DeckCraft is going and in what order. The one-page summary is
|
||||
[ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in
|
||||
[target-app-parity.md](target-app-parity.md). Hours are Opus 5.5 agent wall-clock hours.
|
||||
|
||||
## Alpha gate: PowerPoint's core workflows
|
||||
|
||||
What a typical PowerPoint user does every day, checked end to end on macOS, the main platform
|
||||
(craftrules `standards/progress-docs.md`, "The core-workflow gate"). Every workflow passes, so
|
||||
DeckCraft stays **alpha**. The "partial" rows don't block the workflow, but they are its weakest
|
||||
points and lead the beta plan.
|
||||
|
||||
| Core workflow | Works end to end? | Evidence | Hours to full pass |
|
||||
|---|---|---|---|
|
||||
| Build a deck from scratch (theme, layouts, titles and bullets, shapes, pictures from file), save, close and reopen it | yes | Commands for all of it; `.deckcraft` and `.pptx` round-trip tests (`crates/pptx/tests/roundtrip.rs`, `crates/format`); AutoRecover. Close discards edits without asking (#60, PR #75); image *paste* fails on Windows/Linux (#53), though insert-from-file works | 1–3 |
|
||||
| Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; EMF/WMF pictures render blank; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 |
|
||||
| Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window. Presenter view isn't placed on the second display automatically (drag it there) | 3–5 |
|
||||
| Add business content: tables, charts with their data, simple diagrams | partial (not blocking) | Tables with styles, merge/split, all D or P. Charts insert with the common types and edit data in the Chart Data dialog (P0 partial); SmartArt is basic (pre-drawn shapes) | 12–20 |
|
||||
| Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF with text layer, links, bookmarks, handouts 1–9; PNG/JPEG. Artwork is raster in the PDF; no direct print (print the PDF) | 10–16 |
|
||||
| Review: comments with replies, resolve, spelling | partial (not blocking) | Comments, replies and resolve work and survive `.deckcraft`; threads are flattened on `.pptx` save (#70); spelling is a small built-in list | 4–8 |
|
||||
|
||||
## Current focus (2026-10-10)
|
||||
|
||||
1. **Stop losing work and crashing:** land Close's save prompt (#60, PR #75), the Windows GPU
|
||||
startup crashes (#40, #59), image paste on Windows/Linux (#53). 6–12 h.
|
||||
2. **PPTX on real decks:** build an owned corpus, round-trip checks in CI, fix what breaks;
|
||||
comment threads, media options and rewind on save (#70, #74, #77); EMF/WMF and SVG. 31–55 h.
|
||||
3. **Review the open PRs:** equations (#55), browser print (#66), Morph alignment (#67), macOS
|
||||
open-with (#68), font lookup (#80), Ukrainian (#34, after the catalog exists). 4–8 h.
|
||||
|
||||
## Plan to beta (~120–210 h; ~40–70 h wall clock with 3–4 agents)
|
||||
|
||||
Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` reliably
|
||||
(craftrules `standards/progress-docs.md`). Ranked:
|
||||
|
||||
| # | Work | Hours | Moves |
|
||||
|---|---|---|---|
|
||||
| 1 | Stability: #60, #40/#59, soak and fuzz the editor | 10–20 | Stability 60 → 85% |
|
||||
| 2 | PPTX real-deck corpus and fixes | 20–35 | File formats 58 → 75% |
|
||||
| 3 | PPTX round-trip losses (#70, #74, #77), `.potx`/`.ppsx`/`.pptm` content types, embedded fonts | 8–15 | File formats → 80% |
|
||||
| 4 | EMF/WMF and SVG pictures | 8–14 | File formats, Pictures |
|
||||
| 5 | Vector PDF, printing | 10–16 | File formats, Files |
|
||||
| 6 | Localization infrastructure + zh-Hans, es, fr, de, ja (with IME) | 27–46 | Localization 8 → ~40% |
|
||||
| 7 | Font weights (#69), image paste (#53), Tab in text (#78) | 6–11 | Features |
|
||||
| 8 | Charts: data grid, elements | 6–10 | Features |
|
||||
| 9 | Animation Pane timeline, preview, effect options | 5–8 | Features |
|
||||
| 10 | Presenter view on the right display, timer controls | 3–5 | Hardware, Slide show |
|
||||
| 11 | Native macOS menu bar, Format Shape pane depth, start screen and window chrome fixes | 13–22 | UI/UX 60 → 75% |
|
||||
| 12 | Performance baseline and the obvious fixes | 4–8 | Performance |
|
||||
| **Total** | | **~120–210** | Ready ~55% → ~75% |
|
||||
|
||||
Parallelizes well: 1 (engine/app), 2–5 (pptx, render, pdf), 6 (ui-egui catalogs), 8–11 (UI)
|
||||
touch different crates. Needs a human: the owner opening PPTX outputs in PowerPoint and making
|
||||
reference renders, a Windows tester with the affected GPUs, native speakers to review translations.
|
||||
|
||||
## After beta: toward full parity (~245–440 h more)
|
||||
|
||||
SmartArt editing, chartex chart types, the remaining animation effects and motion paths, Notes and
|
||||
Handout masters, edit points, text warp and 3-D, proofing dictionaries, Zoom, recording, video and
|
||||
GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization and hardware video
|
||||
decode, the other 21 languages with RTL mirroring, a larger original template gallery, and local AI
|
||||
features (owner decision on models). Itemized with hours in [gaps.md](gaps.md).
|
||||
|
||||
## Milestones
|
||||
|
||||
From the local execution plan (`plan/execution-plan.md`, gitignored). Status as of 2026-10-10.
|
||||
|
||||
| # | Milestone | Status | Open |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton and vertical slice | done | |
|
||||
| M1 | Editing core | done | |
|
||||
| M2 | PPTX import/export | done for generated decks | real-deck corpus, round-trip losses |
|
||||
| M3 | Shapes complete | mostly | edit points, Format Shape pane depth, bevel/3-D |
|
||||
| M4 | Design | mostly | Notes/Handout masters, variants, Designer |
|
||||
| M5 | Pictures and media | mostly | SVG/EMF, remove background, compress, icons, WMA/WMV |
|
||||
| M6 | Transitions and animations | mostly | Animation Pane timeline, remaining effects, transition sounds |
|
||||
| M7 | Slide show | mostly | second-display placement, laser, record |
|
||||
| M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table |
|
||||
| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps |
|
||||
| M10 | Views | done | grid options, New Window |
|
||||
| M11 | Output | partly | vector PDF, SVG, GIF, MP4, print |
|
||||
| M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare |
|
||||
| M13 | Automation and CLI parity | done | |
|
||||
| M14 | 1.0 polish and release | partly | i18n, native menu bar, signed releases done (v0.4.0) |
|
||||
|
||||
## History: the 2026-10-07 alpha checklist
|
||||
|
||||
Before the craftrules stages existed, DeckCraft defined "alpha" as: 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 back to `.pptx`/PDF without losing work, and hit no crashes
|
||||
on common paths. On 2026-10-07 it was ~80% there with these blockers:
|
||||
|
||||
| Blocker (2026-10-07) | Status 2026-10-10 |
|
||||
|---|---|
|
||||
| First real release run: signing, notarization, installers verified on each OS | Signed releases v0.1.0–v0.4.0 published; installers not hand-checked on each OS; Windows startup crashes on some GPUs (#40, #59) |
|
||||
| PPTX fidelity on a corpus of real decks | Still open; now beta item 2 |
|
||||
| Presenter view and Animation Pane polish | Still open; beta items 9 and 10 |
|
||||
| Soak and fuzz the editor | Still open; beta item 1 |
|
||||
| UI fidelity pass on the most-used ribbon groups and dialogs; first run | Partly (start-screen fixes #20, #49); beta item 11 |
|
||||
| Mascot app icon, README screenshots, user docs | App icon (with a scalable SVG, #29) and README screenshots in place; user docs open |
|
||||
|
||||
By the standard's definitions DeckCraft is **alpha** (~55% ready); these blockers now feed the beta
|
||||
plan above.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
|
||||
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |
|
||||
@@ -0,0 +1,76 @@
|
||||
# Slide show and presenter view: parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view,
|
||||
rehearsal and recording. Transition and animation playback are in
|
||||
[animation-parity.md](animation-parity.md); display hardware in
|
||||
[hardware-parity.md](hardware-parity.md). Area score: checklist breadth **66%** (measured), **~60%
|
||||
ready** (estimated), **10–16 h** to parity.
|
||||
|
||||
## Running the show
|
||||
|
||||
| Feature | PowerPoint | DeckCraft | Notes |
|
||||
|---|---|---|---|
|
||||
| Play from Start / from Current Slide (F5, Shift+F5, Cmd+Enter) | yes | yes | |
|
||||
| Next/previous by click, arrows, Space, Page Up/Down, Enter, Backspace | yes | yes | #17 reported arrows not working in v0.3.0 AppImage; recheck |
|
||||
| Go to slide (number + Enter), first/last | yes | yes | |
|
||||
| Black / white screen (B / W) | yes | yes | |
|
||||
| End screen, Esc to leave | yes | yes | |
|
||||
| Step clock: a click after a long pause plays its step (#11) | yes | yes | |
|
||||
| Hidden slides skipped | yes | yes | |
|
||||
| Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`) |
|
||||
| Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable |
|
||||
| Animated GIFs play; pause from their corner button | yes | yes (#39) | |
|
||||
| See All Slides grid during the show | yes | no | |
|
||||
| Zoom into part of a slide during the show | yes | no | |
|
||||
| Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt |
|
||||
| Laser pointer | yes | **no** | 1–2 h |
|
||||
| Subtitles and live captions | yes (cloud speech) | no | AI dimension |
|
||||
| Custom shows | yes | yes | |
|
||||
| Set Up Show: loop until Esc, kiosk (browsed at a kiosk), show without animation/narration, pen colour, slides range | yes | yes, most options | |
|
||||
| Use rehearsed timings | yes | yes | |
|
||||
|
||||
## Presenter view
|
||||
|
||||
| Feature | PowerPoint | DeckCraft | Notes |
|
||||
|---|---|---|---|
|
||||
| Separate presenter window with current slide, next slide, notes | yes | yes (an egui viewport, 1100×700) | |
|
||||
| Elapsed timer | yes | yes | |
|
||||
| Pause / reset timer, clock | yes | no | |
|
||||
| Slide counter, previous/next/end buttons | yes | yes | |
|
||||
| Notes font size, scrolling | yes | scrolling only | |
|
||||
| Placed on the presenter's display automatically, show on the projector | yes | **no**: opens wherever the OS puts it | Beta blocker, 3–5 h |
|
||||
| Swap displays | yes | no | |
|
||||
| Pen, laser, black screen, zoom from presenter view | yes | keys only | |
|
||||
| See all slides from presenter view | yes | no | |
|
||||
| Rehearse with Coach (Speaker Coach) | yes (cloud) | no | AI dimension |
|
||||
|
||||
## Rehearse and record
|
||||
|
||||
| Feature | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Rehearse Timings, keep timings | yes | yes |
|
||||
| Record Slide Show (narration, ink, laser, camera; Cameo) | yes | no |
|
||||
| Clear timing / narration | yes | timings only |
|
||||
| Export the recorded show to video | yes | no (see [file-format-parity.md](file-format-parity.md)) |
|
||||
|
||||
## Reading View
|
||||
|
||||
Reading View in a window with navigation: present (checklist row done).
|
||||
|
||||
## Estimates
|
||||
|
||||
| Work | Hours |
|
||||
|---|---|
|
||||
| Presenter view on the right display, swap displays, pause/reset timer, clock, notes size | 3–5 |
|
||||
| Laser pointer, highlighter, eraser, keep-annotations prompt | 2–4 |
|
||||
| See All Slides and zoom during the show | 2–3 |
|
||||
| Record slide show with narration (no camera) | 5–8 |
|
||||
| **Total** | **~12–20** (the slide-show row of the feature table carries 10–16 h; recording overlaps the Media row) |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu |
|
||||
@@ -0,0 +1,282 @@
|
||||
# DeckCraft vs Microsoft PowerPoint: parity assessment
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
|
||||
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
|
||||
[parity-checklist.md](parity-checklist.md) is the row-by-row feature checklist behind the breadth
|
||||
number.
|
||||
|
||||
## Headline
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 36%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md), rows re-scored 2026-10-10 |
|
||||
| Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists |
|
||||
| **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) |
|
||||
| **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) |
|
||||
| **Essentials user** | **~62%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) |
|
||||
| Stage | **alpha** (beta is ~20 points and ~120–210 h away) | judgement against craftrules `standards/progress-docs.md`: ~55% is in the alpha band and all six core workflows pass the [alpha gate](roadmap.md#alpha-gate-powerpoints-core-workflows) |
|
||||
| Remaining effort to beta | **~120–210 Opus 5.5 agent hours** | estimated, itemized in [roadmap.md](roadmap.md) |
|
||||
| Remaining effort to full parity | **~365–650 Opus 5.5 agent hours** | estimated, by dimension below |
|
||||
|
||||
The menu figure is lower than the checklist figure because PowerPoint's menu bar lists every chart
|
||||
type, SmartArt category, action button and media source as its own item, and many DeckCraft
|
||||
equivalents are one parameterized command (`insert.chart {type}`) that the label match cannot see.
|
||||
Read it as a floor, not as a second breadth number.
|
||||
|
||||
## Readiness by audience
|
||||
|
||||
| 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 |
|
||||
|
||||
Calibration is the same as for the full number (see [Calibration](#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.
|
||||
|
||||
## Target and how it was measured
|
||||
|
||||
- **Target:** Microsoft PowerPoint for Mac **16.113.4** (build 16.113.26100421), the Microsoft 365
|
||||
build installed at `/Applications/Microsoft PowerPoint.app`. PowerPoint for Windows has a few
|
||||
more features (Ink Replay, OLE editing, more export types, Hindi and Vietnamese UI); where it
|
||||
matters this document says so.
|
||||
- **The installed app, black-box** (AGENTS.md §2: names and listings only):
|
||||
- `Info.plist` `CFBundleDocumentTypes`: the formats PowerPoint opens and saves (13 roles; table in
|
||||
[file-format-parity.md](file-format-parity.md)).
|
||||
- `Contents/Resources/*.lproj`: 30 localizations plus Base (26 languages and 4 regional variants; table in
|
||||
[localization-parity.md](localization-parity.md)).
|
||||
- The menu-bar dump `plan/powerpoint/menu-tree.txt` (491 lines, observed 2026-10-07, local and
|
||||
gitignored) and the ribbon/pane observations in `plan/powerpoint/01-observed-ui.md`.
|
||||
- **The code on `origin/main` at d556ff9:** 226 engine commands (`crates/engine/src/cmd/*`), 48
|
||||
transitions and 64 animation effects (`crates/model/src/anim.rs`), 504 `#[test]` functions, the
|
||||
PPTX reader/writer (`crates/pptx`, 9.0k lines), 27 dialogs and 10 ribbon tabs
|
||||
(`crates/ui-egui`).
|
||||
- **User reports:** 24 GitHub issues opened 2026-10-08 to 10-10 (16 still open), the best evidence
|
||||
of depth we have.
|
||||
- **Not measured:** rendering fidelity against PowerPoint (no reference renders or real-deck corpus
|
||||
exist yet), performance against PowerPoint, and any workflow end to end with a real user's deck.
|
||||
|
||||
## By dimension
|
||||
|
||||
"Ready" is how close each dimension is to what a professional who presents with PowerPoint needs.
|
||||
The weights are what those users actually depend on, written down so the total can be recomputed.
|
||||
|
||||
| Dimension | Weight | Ready | Hours to full | Doc | Evidence |
|
||||
|---|---:|---:|---|---|---|
|
||||
| Features (breadth × depth) | 30% | ~62% | 140–240 | [parity-checklist.md](parity-checklist.md), [animation-parity.md](animation-parity.md), [slideshow-parity.md](slideshow-parity.md) | Breadth 79% measured. Depth lags: 37 of 191 rows are partial, and users found depth bugs in rows scored done within two days (#53, #60, #69, #70, #74, #77, #78) |
|
||||
| File formats | 20% | ~58% | 60–110 | [file-format-parity.md](file-format-parity.md) | PPTX reads and writes every main part and keeps unknown objects; files open in PowerPoint without repair. But it has never met a corpus of real decks, EMF/WMF/SVG pictures don't decode, comments/media/rewind settings are lost on round trip, and there is no `.ppt`, `.pptm`, `.odp`, video or GIF export |
|
||||
| UI/UX fidelity | 15% | ~60% | 30–50 | [ui-parity.md](ui-parity.md) | All 10 ribbon tabs and the contextual tabs exist; Format Shape pane, Animation Pane, dialogs and menus are thinner than PowerPoint's; no native macOS menu bar; user reports #17, #71, #73, #76 |
|
||||
| Stability | 10% | ~60% | 10–20 | [gaps.md](gaps.md) | Never-crash rules, a panic guard round every command, PPTX fuzz/malformed tests. But startup crashes on some Windows GPUs (#40, #59) and Close discards edits without asking (#60) |
|
||||
| Performance | 5% | ~55% | 10–20 | [hardware-parity.md](hardware-parity.md) | Unmeasured against PowerPoint. Slides rasterize on the CPU (`vello_cpu`); Morph re-aligns outlines every frame (#67) |
|
||||
| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen pressure, no pinch zoom |
|
||||
| Platforms | 5% | ~80% | 5–10 | [ROADMAP.md](../ROADMAP.md) | Ahead in reach: macOS, Windows x64/x86/arm64, Linux (5 formats), FreeBSD and the web, all from one release workflow. Held back by the Windows GPU crashes and Linux/Windows clipboard gaps |
|
||||
| Localization | 5% | ~8% | 55–90 + native review | [localization-parity.md](localization-parity.md) | English only; no string catalog. PowerPoint for Mac ships 26 languages. Bidi and Arabic shaping in slide text exist |
|
||||
| Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes vs PowerPoint's large template gallery; no add-ins or VBA (out of scope by policy) |
|
||||
| AI features | 2% | ~10% | 20–40 (owner decision on models) | [gaps.md](gaps.md) | No Designer, Copilot, Speaker Coach, live subtitles or Translate (all Microsoft cloud services). Agents drive every command over MCP instead |
|
||||
| **Total** | 100% | **~55%** (Σ weight × ready = 55.3) | **~365–650** | | |
|
||||
| Automation (✱, not weighted) | | ahead | 0 | [mcp.md](mcp.md), [control-protocol.md](control-protocol.md) | Every command is scriptable from the CLI, a JSON control channel and MCP, headless or against the running app; PowerPoint for Mac has AppleScript and VBA only |
|
||||
|
||||
About 60% of the hours parallelize across crates (formats, render, text, UI, media, localization
|
||||
catalogs). Needs a human: native-speaker review of every translation, the owner's decision on AI
|
||||
models and on legacy `.ppt` import, running PowerPoint to make reference renders and to confirm
|
||||
round-tripped files open cleanly, and hardware we don't have (Windows GPUs from #40/#59, pens,
|
||||
multi-display rigs).
|
||||
|
||||
## Mainstream practitioner and essentials user
|
||||
|
||||
Two narrower readings of "ready", computed the same way in every Craft app (craftrules
|
||||
`standards/progress-docs.md`). The stage still follows the full number above and the alpha gate.
|
||||
|
||||
### Mainstream practitioner: ~53%
|
||||
|
||||
The typical professional who builds and presents decks every week: a consultant, teacher, sales or
|
||||
product person. Left out: add-ins, Copilot/Designer and other cloud AI, co-authoring and admin
|
||||
features, pens and recording hardware, and languages other than the user's own. Depth here is the
|
||||
depth of the parts of each area this user touches (common chart types, the effects people use,
|
||||
presenting rather than recording), so it's higher than the full-area numbers; exchange with
|
||||
PowerPoint is taken out of the areas and charged once, as a discount.
|
||||
|
||||
| Area (weekly use) | Weight | Depth | Evidence |
|
||||
|---|---:|---:|---|
|
||||
| Text: typing, fonts, paragraphs, bullets | 18 | 70 | Every Home-tab control; font weights (#69), Tab in text boxes (#78) |
|
||||
| Slides, layouts, sections, views | 10 | 82 | Checklist 90% breadth in both areas |
|
||||
| Masters, themes, backgrounds | 8 | 75 | Slide Master view, 8 themes; Notes/Handout masters rarely used |
|
||||
| Shapes, format, arrange | 14 | 75 | Presets, connectors, smart guides; Format Shape pane thinner |
|
||||
| Pictures: insert, crop, styles | 8 | 62 | Works from file; paste fails on Windows/Linux (#53); crop partial |
|
||||
| Tables | 5 | 78 | Styles, merge/split, grow-to-fit |
|
||||
| Charts: column, bar, line, pie with data | 6 | 58 | Chart Data dialog; elements partial |
|
||||
| Transitions and common animations | 8 | 76 | 48/48 transitions; effects cover ~85% of use; pane has no timeline |
|
||||
| Presenting: show, presenter view | 10 | 70 | Works; not auto-placed on the second display; no laser |
|
||||
| Audio and video on slides | 3 | 62 | Plays; slow video in the show on Linux (#91) |
|
||||
| Comments | 3 | 60 | Threads, resolve |
|
||||
| Undo and clipboard | 4 | 75 | Unlimited undo; Paste Special partial |
|
||||
| Open, save, PDF export | 3 | 65 | No print; raster PDF artwork |
|
||||
| **Weighted depth** | 100 | **71.3** | |
|
||||
|
||||
| Discount | Factor | Evidence |
|
||||
|---|---:|---|
|
||||
| Interaction fidelity | ×0.92 | No native macOS menu bar, thinner Format Shape pane, Tab ends text entry (#78), font faces (#69), window chrome (#71) |
|
||||
| Stability on real machines | ×0.92 | Startup crashes on some Windows GPUs (#40, #59); Close discards edits (#60, fix in PR #75); video stalls in the editor on Ubuntu (#91); arrows reported dead in the show on Linux Mint (#17) |
|
||||
| File exchange with PowerPoint users | ×0.88 | Harsher than VectorCraft's ×0.92 because swapping `.pptx` is daily work for this user: never tested on real decks, EMF pictures blank, icons from PowerPoint don't come through (#56), comment threads, media options and rewind lost on save (#70, #74, #77) |
|
||||
|
||||
71.3 × 0.92 × 0.92 × 0.88 = **53.1 → ~53%**. This is about the same as the full number, unlike
|
||||
apps where mainstream comes out well above it. The dimensions mainstream leaves out
|
||||
(localization, ecosystem, AI) weigh only 10% of DeckCraft's full number, and the biggest penalty,
|
||||
`.pptx` exchange, hits every mainstream user.
|
||||
|
||||
### Essentials user: ~62%
|
||||
|
||||
Someone who makes a few simple decks: open or start a deck, type, add pictures, present, export
|
||||
a PDF. Leaves out advanced options, pro workflows and exchange edge cases, plus everything the
|
||||
mainstream number leaves out.
|
||||
|
||||
| Core feature | Weight | Depth | Evidence |
|
||||
|---|---:|---:|---|
|
||||
| Start a deck from a theme (start screen) | 8 | 70 | Works; layout problems on the start screen (#17, #76) |
|
||||
| Type titles and bullets, basic formatting | 20 | 78 | Works; #69, #78 |
|
||||
| New slide, layout, reorder, duplicate | 12 | 88 | Works |
|
||||
| Insert a picture | 10 | 65 | From file yes; paste fails on Windows/Linux (#53) |
|
||||
| Basic shapes and text boxes | 8 | 85 | Works |
|
||||
| Theme colours and background | 6 | 80 | Works |
|
||||
| Transitions | 6 | 85 | Works |
|
||||
| Simple animations | 5 | 80 | Works |
|
||||
| Present full screen and move through slides | 10 | 85 | Works; #17 on Linux Mint |
|
||||
| Undo / redo | 5 | 95 | Works |
|
||||
| Save and reopen | 6 | 85 | Works; Close doesn't ask to save (#60) |
|
||||
| Export PDF / print | 4 | 70 | PDF yes, print no |
|
||||
| **Weighted depth** | 100 | **80.1** | |
|
||||
|
||||
| Discount | Factor | Evidence |
|
||||
|---|---:|---|
|
||||
| Launch and stability | ×0.90 | Some Windows PCs can't start the app (#40, #59), and the workaround is an environment variable; Close loses work (#60) |
|
||||
| Discoverability and UI clarity | ×0.95 | Familiar ribbon and tabs; no native menu bar, start-screen glitches, a command palette whose default entry fails (#79) |
|
||||
| Opening files people send them | ×0.90 | `.pptx` opens, but real decks are untested and EMF pictures and PowerPoint icons can come in blank (#56) |
|
||||
|
||||
80.1 × 0.90 × 0.95 × 0.90 = **61.6 → ~62%**.
|
||||
|
||||
### User evidence (GitHub, 2026-10-10, maintainers excluded)
|
||||
|
||||
- **38 issues** from 22 reporters and **48 PRs** by people other than the owner (bidi/Arabic,
|
||||
Morph, tables, logging, packaging, a Gentoo overlay; open fix PRs for #53, #64, #70, #72, #74,
|
||||
#77). **Praise threads or "switched from PowerPoint" reports: 0**
|
||||
so far; the only comment threads are "same here" confirmations (#17, #33).
|
||||
- **Open issues (27):** 13 on the core path (#91 video playback, #60 Close, #59 and #40 startup
|
||||
crashes, #53 image paste, #78 Tab in text, #69 font faces, #70/#74/#77 `.pptx` round trip, #56
|
||||
icons from PowerPoint, #17 show navigation and file menu, #76 start screen), 14 polish or niche
|
||||
(#81 and #64 browser exports, #79 palette, #72 browser comment timestamps, #73 icon consistency,
|
||||
#71 and #26 window chrome, #41 system theme, #32 text transparency, #18 desktop shortcut, #15 and
|
||||
#30 languages, #58 README link, #50 DMG signature).
|
||||
- Reading: people try DeckCraft for real decks and hit the core path. Nobody has reported
|
||||
replacing PowerPoint yet, which fits ~53–55%.
|
||||
|
||||
## By feature area
|
||||
|
||||
Breadth is measured from the checklist; "ready" adds depth and is estimated. Hours are to
|
||||
PowerPoint parity for that area. **Weight** is how much a PowerPoint user relies on the area
|
||||
(points out of 110); the total "ready" is the weighted mean of the rows (6,865 / 110 = 62.4%), and
|
||||
it is the Features row of the dimension table. Automation and Platforms are scored elsewhere.
|
||||
|
||||
| Area | Weight | Breadth (measured) | Ready (est.) | Hours | Main shortfalls | Doc |
|
||||
|---|---:|---:|---:|---|---|---|
|
||||
| Application shell | 5 | 71% | ~55% | 8–14 | No native macOS menu bar, thin start screen (templates, recent), one window per presentation missing | [ui-parity.md](ui-parity.md) |
|
||||
| Files (commands) | 4 | 69% | ~50% | see file formats | Print, reuse slides, passwords, legacy and macro formats | [file-format-parity.md](file-format-parity.md) |
|
||||
| Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) |
|
||||
| Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) |
|
||||
| Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) |
|
||||
| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) |
|
||||
| Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) |
|
||||
| Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) |
|
||||
| Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) |
|
||||
| Tables | 5 | 89% | ~75% | 3–6 | Draw table, borders and cell effects depth | [parity-checklist.md](parity-checklist.md) |
|
||||
| Charts | 7 | 65% | ~45% | 12–20 | Data grid editing, chart elements, the `chartex` types (waterfall, funnel, treemap, sunburst, histogram, box) | [parity-checklist.md](parity-checklist.md) |
|
||||
| SmartArt | 4 | 33% | ~25% | 10–16 | Text pane, layout switching, the diagram data model on save | [parity-checklist.md](parity-checklist.md) |
|
||||
| Pictures | 7 | 48% | ~45% | 12–20 | SVG/EMF/WMF, clipboard paste on Windows/Linux (#53), artistic effects, remove background, compress | [file-format-parity.md](file-format-parity.md) |
|
||||
| Media | 4 | 67% | ~60% | 8–14 | WMA/WMV decode, record audio/screen, icons and 3-D models, playback options lost on PPTX round trip (#74) | [file-format-parity.md](file-format-parity.md) |
|
||||
| Transitions | 4 | 92% | ~80% | 4–8 | Transition sounds, 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) |
|
||||
| Animations | 6 | 79% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) |
|
||||
| Slide show and presenter view | 8 | 66% | ~60% | 10–16 | Second-display placement, laser pointer, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) |
|
||||
| Review | 3 | 60% | ~45% | 8–14 | Modern comment threads on round trip (#70), compare, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) |
|
||||
| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen pressure, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) |
|
||||
| Undo and clipboard | 3 | 88% | ~75% | 3–5 | Paste Special, image paste (#53) | [ui-parity.md](ui-parity.md) |
|
||||
| Automation ✱ | — | 100% | ahead | 0 | | [mcp.md](mcp.md) |
|
||||
| Platforms ✱ | — | 67% | ~80% | see platforms | | [ROADMAP.md](../ROADMAP.md) |
|
||||
| **Total (feature areas)** | 110 | **79%** | **~62%** | **~140–240** | | |
|
||||
|
||||
## What works today (evidence)
|
||||
|
||||
Carried from the 2026-10-07 ROADMAP status and updated with what has landed since.
|
||||
|
||||
- **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 on shared slide snapshots.
|
||||
- **Themes:** 8 original themes with colour and font schemes, custom colours and fonts,
|
||||
backgrounds (solid, gradient, picture, pattern), slide size presets.
|
||||
- **Shapes:** ~150 preset geometries with adjustment handles, text boxes, pictures (crop,
|
||||
corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line,
|
||||
area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites,
|
||||
freeform/curve/scribble, merge shapes, ink, action buttons, WordArt; flipped shapes keep their
|
||||
text readable.
|
||||
- **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; date fields in
|
||||
their own format; hyperlinks drawn in the theme's hyperlink colour and followed in the show.
|
||||
UAX #9 bidi and Arabic shaping with HarfRust, script font slots with per-cluster fallback,
|
||||
`justLow` kashida, bidi caret and selection.
|
||||
- **Editing:** select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate;
|
||||
z-order; group/ungroup; align/distribute; Format Painter; Selection pane; clipboard.
|
||||
- **Transitions and animations:** 48 transitions including Morph (objects, words, characters,
|
||||
geometry outlines), 64 animation effects (entrance, emphasis, exit, motion paths, media),
|
||||
triggers, by-paragraph builds, Animation Painter, 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; links and shape
|
||||
actions followed on click.
|
||||
- **Views:** Normal, Outline, Slide Sorter, Notes Page, Reading View, Slide Master; zoom;
|
||||
grayscale.
|
||||
- **Review:** comments with replies and resolve, accessibility checker, a basic spelling list.
|
||||
- **Media:** 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 with pure-Rust decoders; poster frames, trim, fades,
|
||||
volume, loop, play across slides.
|
||||
- **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF
|
||||
export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline
|
||||
text, AutoRecover.
|
||||
- **Automation:** 226 commands, every one reachable from `deckcraft-cli`, the JSON control channel
|
||||
and MCP.
|
||||
- **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux
|
||||
(AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09).
|
||||
|
||||
## Calibration: how the hours were derived
|
||||
|
||||
From this repo's own history (`git log`, `gh pr list`, the 2026-10-07 ROADMAP):
|
||||
|
||||
- **The bulk build:** engine, model, renderer, text, UI, show engine, PPTX, PDF, media and the
|
||||
release pipeline (today 117k lines of Rust, 59k of them media codecs and containers ported from FilmCraft) took about
|
||||
**30 wall-clock hours with up to three agents**, i.e. roughly 60–90 agent hours, landing publicly
|
||||
between 2026-10-05 22:13 and 10-06 02:28. That is ~100 agent hours for today's 79% breadth / ~55%
|
||||
ready, with community PRs.
|
||||
- **Catalogue rows** (connectors with glue, freeform tools, merge shapes, AutoRecover, the gradient
|
||||
editor) took **1–2 agent hours each** to reach "done" (commits 10-06 00:49–01:34, parallel agents).
|
||||
- **Recent PRs** (2026-10-08 to 10-10): Animation Painter +189 lines, animated GIF playback +393,
|
||||
hyperlink fixes +474, Morph geometry +781, UAX #9 bidi and Arabic shaping +1,902. At the ~150–300
|
||||
tested lines per agent hour these suggest, that is 0.5–1 h, 1–3 h and 6–12 h respectively.
|
||||
PR open-to-merge times (often 10–30 h) measure the review queue, not the work, and were not used.
|
||||
|
||||
Scale used: a fix 0.5–1 h; a checklist row to presence 1–2 h; a row to PowerPoint depth 2–4 h; a
|
||||
subsystem (localization infrastructure, a real-deck corpus with fixes, EMF rendering) 6–35 h. The
|
||||
tail costs more per point than the bulk did: the remaining work is depth, fidelity and verification,
|
||||
which the first 100 hours mostly skipped.
|
||||
|
||||
**Why the full-parity estimate rose from ~190 h (2026-10-07) to ~365–650 h:** the old figure
|
||||
covered the checklist rows, ribbon depth and performance only (~135 h of it is comparable to this
|
||||
document's 140–240 h for features plus 30–50 h for UI). New in this measure: localization (55–90 h;
|
||||
there is none yet), hardware (25–45 h), legacy and missing formats including `.ppt`, `.odp`, EMF/WMF
|
||||
and video export, a real-deck PPTX corpus, AI features, and the depth bugs users found in features
|
||||
scored done (16 open issues).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages |
|
||||
| 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha |
|
||||
| 2026-10-10 | major | Created from ROADMAP.md's "How far from full parity" and "Status" sections; full re-measure against PowerPoint for Mac 16.113.4 (Info.plist, lproj, menu dump), 226 commands, 191-row checklist (79%), weighted dimension table (~56% ready), hours recalibrated from git history |
|
||||
@@ -0,0 +1,90 @@
|
||||
# UI parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; ribbon, menus, panes, dialogs and interaction compared with PowerPoint 16.113 observations in `plan/powerpoint/`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
|
||||
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
|
||||
`plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in
|
||||
AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to parity.
|
||||
|
||||
## Shell
|
||||
|
||||
| Element | PowerPoint for Mac | DeckCraft | Gap |
|
||||
|---|---|---|---|
|
||||
| Title bar: AutoSave, Home, Save, Undo/Redo, document title, Share, search | yes | yes, no Share (cloud) | #71 macOS window controls misaligned |
|
||||
| Native macOS menu bar (File, Edit, View, Insert, Format, Arrange, Tools, Slide Show, Window, Help) | yes | **no** (`menus::menu_tree()` is defined but not installed) | 3–5 h |
|
||||
| Menu-bar items with a DeckCraft equivalent | 217 app-specific items | 128 matched by name (59%, heuristic lower bound) | See [target-app-parity.md](target-app-parity.md) |
|
||||
| Ribbon tabs: Home, Insert, Draw, Design, Transitions, Animations, Slide Show, Record, Review, View | 10 | 10 | Record tab is a placeholder |
|
||||
| Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner |
|
||||
| Start screen: new, themes, templates, recent, open | yes | yes, thin (#17 broken layout, #76 missing overlay) | 3–5 h |
|
||||
| Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | |
|
||||
| Command search (Tell Me) | yes | command palette | #79 default entry fails |
|
||||
| Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h |
|
||||
| One window per presentation, New Window, Arrange All | yes | tabs in one window; no New Window | 3–5 h |
|
||||
| Quick Access Toolbar customization | yes | no | P1 partial |
|
||||
|
||||
## Panes and dialogs
|
||||
|
||||
| Pane / dialog | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
|
||||
| Format Background pane | yes | yes |
|
||||
| Selection Pane | yes | yes |
|
||||
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
|
||||
| Comments pane (threads) | yes | yes |
|
||||
| Design Ideas (Designer) | yes (cloud) | no |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
|
||||
## Direct manipulation
|
||||
|
||||
| Behaviour | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Selection handles, rotation handle, yellow adjustment handles | yes | yes |
|
||||
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
|
||||
| Smart guides (edges, centres, equal spacing) | yes | yes |
|
||||
| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle |
|
||||
| Nudge (arrows), fine nudge (Option+arrows) | yes | yes |
|
||||
| Shift-constrained move, Option-drag to duplicate | yes | yes |
|
||||
| Connector glue and rerouting | yes | yes |
|
||||
| Edit points on freeforms | yes | **no** |
|
||||
| Crop handles, crop to shape, aspect ratio | yes | partial |
|
||||
| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) |
|
||||
| Exact size/position, lock aspect | yes | yes |
|
||||
| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial |
|
||||
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter |
|
||||
| Drag and drop of files and between thumbnails | yes | yes |
|
||||
| Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) |
|
||||
| Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) |
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
Every common PowerPoint for Mac shortcut is bound (checklist row done): new slide, duplicate,
|
||||
group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, show from
|
||||
start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag
|
||||
precision without snapping, F6 pane cycling, ribbon KeyTips (Windows).
|
||||
|
||||
## Visual fidelity
|
||||
|
||||
Colours, spacing, ribbon card and tab geometry follow the observations in
|
||||
`plan/powerpoint/01-observed-ui.md`; icons are original (~240, drawn in code or Lucide) and must not
|
||||
imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been run since
|
||||
2026-10-07. Cross-app icon consistency is open (#73, #56).
|
||||
|
||||
## Estimates
|
||||
|
||||
| Work | Hours |
|
||||
|---|---|
|
||||
| Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 |
|
||||
| Format Shape pane depth | 6–10 |
|
||||
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 |
|
||||
| Start screen, window chrome, system appearance, palette fixes (#17, #41, #71, #76, #79) | 4–7 |
|
||||
| Grid Options, edit points UI, crop depth | 4–7 |
|
||||
| Multiple windows | 3–5 |
|
||||
| A screenshot comparison pass on the most-used tabs | 2–4 |
|
||||
| **Total** | **~30–52** |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |
|
||||
@@ -0,0 +1,11 @@
|
||||
kind = "app"
|
||||
categories = ["Office"]
|
||||
icon = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png"
|
||||
|
||||
[linux.x86_64]
|
||||
asset = "deckcraft-{version}-linux-x86_64.tar.gz"
|
||||
exec = "deckcraft-{version}-linux-x86_64/bin/deckcraft"
|
||||
|
||||
[linux.aarch64]
|
||||
asset = "deckcraft-{version}-linux-aarch64.tar.gz"
|
||||
exec = "deckcraft-{version}-linux-aarch64/bin/deckcraft"
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and package DeckCraft for Linux (<arch> is x86_64 or aarch64):
|
||||
# Build and package DeckCraft for Linux (<arch> is x86_64 or aarch64, or riscv64 when cross-compiling):
|
||||
#
|
||||
# $DIST/deckcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
|
||||
# $DIST/deckcraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake)
|
||||
@@ -30,8 +30,9 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
ARCH="${CROSS_ARCH:-$(uname -m)}"
|
||||
case "$ARCH" in
|
||||
riscv64) DEB_ARCH=riscv64 ;;
|
||||
x86_64) DEB_ARCH=amd64 ;;
|
||||
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
|
||||
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
|
||||
@@ -42,9 +43,13 @@ BASENAME="deckcraft-$VERSION-linux-$ARCH"
|
||||
echo "==> DeckCraft $VERSION for Linux $ARCH ($FORMATS)"
|
||||
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p deckcraft -p deckcraft-cli)
|
||||
if [ -n "${CROSS_TARGET:-}" ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p deckcraft -p deckcraft-cli --target "$CROSS_TARGET")
|
||||
else
|
||||
(cd "$ROOT" && cargo build --release --locked -p deckcraft -p deckcraft-cli)
|
||||
fi
|
||||
fi
|
||||
BIN="$CARGO_TARGET_DIR/release"
|
||||
BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
|
||||
WORK="$CARGO_TARGET_DIR/linux-package"
|
||||
STAGE="$WORK/root"
|
||||
rm -rf "$WORK"
|
||||
@@ -52,7 +57,7 @@ rm -rf "$WORK"
|
||||
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
|
||||
install -Dm755 "$BIN/deckcraft" "$STAGE/usr/bin/deckcraft"
|
||||
install -Dm755 "$BIN/deckcraft-cli" "$STAGE/usr/bin/deckcraft-cli"
|
||||
strip "$STAGE/usr/bin/deckcraft" "$STAGE/usr/bin/deckcraft-cli" 2>/dev/null || true
|
||||
"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/deckcraft" "$STAGE/usr/bin/deckcraft-cli" 2>/dev/null || true
|
||||
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
|
||||
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
|
||||
mkdir -p "$STAGE/usr/share/metainfo"
|
||||
@@ -135,6 +140,10 @@ if has appimage; then
|
||||
fi
|
||||
fi
|
||||
|
||||
"$STAGE/usr/bin/deckcraft-cli" --version
|
||||
if [ -z "${CROSS_TARGET:-}" ]; then
|
||||
"$STAGE/usr/bin/deckcraft-cli" --version
|
||||
elif [ -n "${EMULATOR:-}" ]; then
|
||||
"$EMULATOR" "$STAGE/usr/bin/deckcraft-cli" --version
|
||||
fi
|
||||
echo "==> done"
|
||||
ls -lh "$DIST"
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ commands:
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity recompute the PowerPoint feature-parity summary in docs/parity.md
|
||||
parity recompute the PowerPoint feature-parity summary in docs/parity-checklist.md
|
||||
ico <out.ico> <in.png>...
|
||||
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
|
||||
";
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity.md from its row
|
||||
//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity-checklist.md from its row
|
||||
//! table (each PowerPoint feature scored D / P / M, weighted by priority).
|
||||
|
||||
use std::path::Path;
|
||||
@@ -69,10 +69,10 @@ pub fn summary(rows: &[(String, String, u8, f64)]) -> String {
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let path = root.join("docs/parity.md");
|
||||
let path = root.join("docs/parity-checklist.md");
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let (Some(a), Some(b)) = (text.find(START), text.find(ROWS)) else {
|
||||
return Err("docs/parity.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into());
|
||||
return Err("docs/parity-checklist.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into());
|
||||
};
|
||||
let rows = rows(&text[b..]);
|
||||
let s = summary(&rows);
|
||||
|
||||
Reference in New Issue
Block a user