Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 cc2dc9e99c Tests: the equation-wrap test accepts plain y where no math font is installed (FreeBSD CI)
The first glyph is math italic 𝑦 only when a font has it; FreeBSD CI has none, so
it shapes as plain y. The test is about line breaks and indents, not the glyph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:04:00 -04:00
573 changed files with 22571 additions and 288704 deletions
-6
View File
@@ -1,6 +0,0 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
-1
View File
@@ -1 +0,0 @@
craft-i18n.rev text eol=lf
-30
View File
@@ -1,30 +0,0 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
-28
View File
@@ -1,28 +0,0 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+36 -11
View File
@@ -2,18 +2,16 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
push:
branches: [release]
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/fonts/**"
- .github/workflows/freebsd.yml
- packaging/env.sh
schedule:
- cron: "27 4 * * *"
workflow_dispatch:
concurrency:
@@ -25,7 +23,34 @@ permissions:
jobs:
freebsd:
# 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: b076417b5880bd4154bd22f00695b1d28c6bea83
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: 8dcdacd5153e64560d109541a47d806f26f048c0
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
-23
View File
@@ -1,23 +0,0 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# 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/nix.yml@main
+64 -2
View File
@@ -19,5 +19,67 @@ concurrency:
jobs:
lint:
# 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
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/wordcraft.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/wordcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
# Each Craft app needs its own MSI UpgradeCode and component GUIDs. Sharing one with a
# sibling makes Windows Installer treat the two apps as versions of one product (#4).
# Codes the sibling apps' installers use:
siblings='D9660F32-2D0A-4ABE-B73D-17BE25F1542C A6DE41B7-A80F-4C59-A17E-BB68065BF67E
601C6437-08AE-4645-ADCC-27F036C2EA2F 0E8C6C5E-DE4A-4E1D-9DB0-44E8E38C2103
31E6FB14-9DBC-41DD-B24B-26B71FB31B62 15933017-925B-4963-8992-52000EB26AAA
229BB552-5EFD-479A-BA86-8CB6D926390C 8A1D3A39-F7C1-47B1-97AE-40C80BF82F4A
EA00C367-7270-4C50-9D8B-A7A5C88AD27F E724C1C6-091F-4E21-A685-80D089FDBE21
DC028192-C233-4F99-9A77-ED458C60DF26 F6948627-32AB-4D78-8865-0FDF417CE442
AACA8593-C6ED-40A8-BA6C-CB325D8529F9 BA719860-95FE-4154-B265-47006B8E9083
49E87D30-BB68-4896-AA1D-F45C833CFC3C DE018EF3-EE87-4E36-9F76-0B7B060A195E'
for code in $siblings; do
if grep -qi "$code" packaging/windows/wordcraft.wxs; then
echo "::error file=packaging/windows/wordcraft.wxs::$code belongs to a sibling Craft app's MSI; generate a new GUID"; exit 1
fi
done
if [ "$(grep -oiE '(UpgradeCode|Guid)="[0-9A-F-]{36}"' packaging/windows/wordcraft.wxs | cut -d'"' -f2 | sort -f | uniq -di)" ]; then
echo "::error file=packaging/windows/wordcraft.wxs::duplicate GUID"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.wordcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.wordcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.wordcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.wordcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.wordcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.wordcraft" and m["command"] == "wordcraft", 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
-73
View File
@@ -1,73 +0,0 @@
# Exercise the native print host and all UI tests on PRs; the broad FreeBSD/ARM64 gates
# stay on their existing nightly/release schedule.
name: Printing
on:
pull_request:
paths:
- 'Cargo*'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
- '.github/workflows/printing.yml'
push:
branches: [main]
paths:
- 'Cargo*'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
- '.github/workflows/printing.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: printing-${{ github.ref }}
cancel-in-progress: true
jobs:
native:
name: Print (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Linux build and software-rendering dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Formatting
run: cargo fmt --all -- --check
- name: Native app and UI Clippy
run: cargo clippy -p wordcraft -p wordcraft-ui-egui --all-targets --locked -- -D warnings
- name: Native app and UI tests
run: cargo test -p wordcraft -p wordcraft-ui-egui --locked
- name: Browser compatibility
if: runner.os == 'Linux'
run: cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked
+406 -24
View File
@@ -1,3 +1,12 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "WordCraft v<version>". See docs/releasing.md.
#
# The signing jobs (macOS, Windows) and the job 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
# jobs that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no
# secrets, so a `workflow_dispatch` on any branch dry-runs them; there the signing jobs are refused
# by the environment's branch rule, and the release job, which needs them, is skipped.
name: Release
on:
@@ -11,34 +20,407 @@ on:
default: ""
permissions:
contents: write
contents: read
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
WORDCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
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/env.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 "### WordCraft 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:
WORDCRAFT_VERSION: ${{ needs.version.outputs.version }}
WORDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
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 "$WORDCRAFT_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 and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
WORDCRAFT_VERSION: ${{ needs.version.outputs.version }}
WORDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
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 "$WORDCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: |
$PSNativeCommandUseErrorActionPreference = $true # stop at the first failing command
dotnet tool install --global wix --version 5.0.2
# Installer dialogs and the finish page's "Launch WordCraft" (packaging/windows/wordcraft.wxs).
wix extension add -g WixToolset.UI.wixext/5.0.2
wix extension add -g WixToolset.Util.wixext/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). See docs/releasing.md.
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:
WORDCRAFT_VERSION: ${{ needs.version.outputs.version }}
WORDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
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 "$WORDCRAFT_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/wordcraft$|metainfo|applications'
ldd target/release/wordcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.wordcraft.yml')); assert m['id'] == 'ai.storyteller.wordcraft'"
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); no Rust
# build here. 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:
WORDCRAFT_VERSION: ${{ needs.version.outputs.version }}
WORDCRAFT_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 renders the scalable icon and fails 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/wordcraft-$WORDCRAFT_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
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
WORDCRAFT_VERSION: ${{ needs.version.outputs.version }}
WORDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
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 "$WORDCRAFT_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
freebsd:
name: FreeBSD x86_64
needs: version
runs-on: ubuntu-latest
timeout-minutes: 300
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
WORDCRAFT_VERSION: ${{ needs.version.outputs.version }}
WORDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
# Synced into the VM with the sources.
- name: Check out craft-fonts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts
- name: Build in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
envs: "WORDCRAFT_VERSION WORDCRAFT_BUILD_DATE WORDCRAFT_BUILD_SHA"
copyback: true
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"
git config --global --add safe.directory "*"
cargo run -q -p xtask -- version set "$WORDCRAFT_VERSION"
CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 cargo build --release --locked -p wordcraft -p wordcraft-cli
name="wordcraft-$WORDCRAFT_VERSION-freebsd-x86_64"
mkdir -p "dist/release/$name"
cp target/release/wordcraft target/release/wordcraft-cli LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md README.md "dist/release/$name/"
cp packaging/linux/ai.storyteller.wordcraft.desktop "dist/release/$name/"
cp assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png "dist/release/$name/"
# The embedded craft-fonts' licences, as in the Linux packages (OFL-<family>.txt).
for f in craft-fonts/fonts/*/OFL.txt; do cp "$f" "dist/release/$name/OFL-$(basename "$(dirname "$f")").txt"; done
tar -C dist/release -czf "dist/release/$name.tar.gz" "$name"
rm -rf "dist/release/$name" target
- uses: actions/upload-artifact@v4
with:
name: release-freebsd
path: dist/release/*.tar.gz
if-no-files-found: error
release:
# 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: wordcraft
app-title: WordCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
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 }}
name: Draft GitHub Release
# Needs the signing jobs, which only run on `release`: a branch dry run never gets here.
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="WordCraft 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"
-21
View File
@@ -1,21 +0,0 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+78 -11
View File
@@ -4,17 +4,19 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "13 6 * * *"
push:
branches: [release]
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
@@ -24,10 +26,75 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
windows-arm64:
# 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/windows-arm64.yml@main
with:
app-name: wordcraft
app-dir: WordCraft
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: |
$PSNativeCommandUseErrorActionPreference = $true # stop at the first failing command
dotnet tool install --global wix --version 5.0.2
# Installer dialogs and the finish page's "Launch WordCraft" (packaging/windows/wordcraft.wxs).
wix extension add -g WixToolset.UI.wixext/5.0.2
wix extension add -g WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: wordcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: wordcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'WordCraft'
foreach ($exe in 'wordcraft.exe', 'wordcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'wordcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "wordcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok wordcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'wordcraft.exe')) { throw 'uninstall left wordcraft.exe behind' }
Write-Output 'ok uninstalled'
-1
View File
@@ -43,7 +43,6 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
## Quality gates
+2 -10
View File
@@ -8,17 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `egui` dependency: `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Small egui special-icon subset; embedded by default_fonts, retained in FontDefinitions::default() |
| `egui` dependency: `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Parent monochrome icon/emoji face embedded by monochrome_emoji_fonts; separate from the egui-icons subset |
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
| `assets/spelling/en-variants.txt` | WordCraft contributors | original list written for this repository from general knowledge of English spelling; no other dictionary or variant list copied | MIT OR Apache-2.0 | ~280 American/British spelling pairs (-or/-our, -er/-re, -ize/-ise, -yze/-yse, -ense/-ence, doubled l, ae/oe…) with a policy per English: en-US, en-CA and the British-spelling Englishes (#1281, #1045) |
| `assets/spelling/ru-extra.txt` | WordCraft contributors | original word list written for this repository (common modern Russian words older Hunspell dictionaries lack, with their declension classes); no other dictionary copied | MIT OR Apache-2.0 | ~200 Russian base forms supplementing spelling; their forms are generated by `crates/proof/src/ru/extra.rs` |
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
| `assets/hyphenation/de.pat` | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr (German TeX hyphenation patterns, reformed spelling, 2013–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-de-1996.tex`, SHA-256 `374ad1ce3263f8a2791ec070ef9b516c532dae64456f3fb656e617e8ae53a9d3`), bundled unmodified | MIT (the notice is in the file's header) | 36,709 Liang patterns, no exception words; German hyphenation (de-DE, de-AT, de-CH) |
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](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") |
@@ -69,4 +60,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
Generated
+371 -2179
View File
File diff suppressed because it is too large Load Diff
+10 -57
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
rust-version = "1.90"
repository = "https://github.com/storytold/wordcraft"
[workspace.dependencies]
@@ -35,62 +35,39 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
rayon = "1"
unicode-bidi = "0.3"
unicode-segmentation = "1"
unicode-linebreak = "0.1"
unicode-script = "0.5"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
regex-automata = { version = "0.4", default-features = false, features = ["std", "syntax", "meta", "nfa-pikevm", "unicode"] }
# text / rendering / formats
skrifa = "0.47"
harfrust = "0.13"
# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders
# (brotli-decompressor, flate2) are already in the tree.
wuff = "0.2.9"
# WOFF2's brotli stream, decoded ahead of wuff so its glyph point counts are checked first (#3703).
# The same crate and version wuff already decodes it with (no new code in the build): pure Rust,
# `forbid(unsafe_code)` in its default build, no_std-capable (builds for wasm), BSD-3-Clause/MIT.
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", default-features = false, features = ["write"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] }
quick-xml = "0.41"
krilla = { version = "0.8", features = ["pdf"] }
quick-xml = "0.38"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
miniz_oxide = "0.9"
cfb = "0.15"
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
# detector chardetng (Apache-2.0 OR MIT), both pure Rust (Firefox's).
encoding_rs = "0.8"
chardetng = "1"
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
aes = "0.9"
sha1 = "0.11"
sha2 = "0.11"
hmac = "0.13"
base64 = "0.23"
base64 = "0.22"
getrandom = "0.3"
zeroize = "1"
# Unix open flags (O_NONBLOCK for the engine's checked file reads, #3666); already in the lock as a
# dependency of other crates.
libc = "0.2"
# ui
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
# A monotonic clock that works on native and in the browser (`performance.now()`).
web-time = "1.1"
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.7", default-features = false, features = ["wayland-data-control"] }
arboard = { version = "3.6", default-features = false }
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -101,38 +78,14 @@ todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
# Line tables keep file and line in panics and backtraces; full debug info
# for every test binary and dependency would multiply the target dir size.
# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+2 -97
View File
@@ -23,104 +23,9 @@ list (author, source, license) is in ATTRIBUTION.md.
- English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by
Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
(assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0.
- Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8
package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled
unmodified and used under the MIT License; the copyright and permission notice is in the file.
- Portuguese hyphenation patterns (assets/hyphenation/pt.pat) — hyph-pt.tex from the hyph-utf8
package, bundled unmodified and used under the BSD 3-Clause License:
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
2024 Leonardo Araujo and Aline Benevides
Redistribution and use in source and binary forms, with or without modification, are
permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of
conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of
conditions and the following disclaimer in the documentation and/or other materials
provided with the distribution.
* Neither the name of the University of Campinas, of the University of Minho nor the names
of its contributors may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO
J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
OF THE POSSIBILITY OF SUCH DAMAGE.
- German hyphenation patterns (assets/hyphenation/de.pat) — hyph-de-1996.tex (reformed spelling)
from the hyph-utf8 package, bundled unmodified and used under the MIT License; the copyright
and permission notice is in the file:
Copyright (c) 2013-2024 Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven,
Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
Copyright (C) 2008-2011 Claudio Beccari
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
- Application-drawn UI icons (crates/ui-egui/src/icons.rs) are original work, MIT OR Apache-2.0;
the bundled egui font icons have the separate notice below.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
b926b5c03e256c86ca10934f58d03f628810b5b7. The backend retains its MIT OR Apache-2.0 licensing
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
The backend patch prevents finalization during an unwind and rejects a writer after failed
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
Bundled egui font icons
The canonical egui0.37 dependency includes egui-icons.ttf (a small subset of emoji-icon-font)
through default_fonts, and emoji-icon-font.ttf through monochrome_emoji_fonts. Both are embedded
by the UI font definitions. Their upstream project is https://github.com/jslegers/emoji-icon-font;
the exact copyright and permission notice from the pinned egui dependency follows.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+29 -91
View File
@@ -7,18 +7,14 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png" width="128" height="128" alt="WordCraft icon: a document page and pen on a blue tile">
</p>
<h1 align="center">WordCraft</h1>
<p align="center">
<b>Writing and document design; an open-source, modern word processor, built in pure Rust.</b>
<b>Writing and document design; an open-source, clean-room reimplementation of Microsoft Word, rebuilt in pure Rust.</b>
</p>
<p align="center">
A fast, open-source word processor with a familiar workflow: the ribbon, styles,
A fast, open-source word processor with the Word workflow you already know: the ribbon, styles,
tables, track changes, references and mail merge. It reads and writes .docx, runs natively on
macOS, Windows, Linux and BSD, and in the browser via WebAssembly.<br>
<i>By the ArtCraft team.</i>
@@ -29,9 +25,6 @@
<img alt="Runs on macOS, Windows, Linux, BSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-3b5bdb?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-2b47b5?style=flat-square">
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP%20%C2%B7%20CLI-3b5bdb?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_wordcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/wordcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -93,10 +86,10 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
## Why WordCraft
- **Familiar.** Ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
- **Familiar.** Word's ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
Tab demotes it, Ctrl/⌘+B bolds the word under the caret, the Styles gallery previews styles live,
F4 repeats, F7 checks spelling, F8 extends the selection.
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens legacy 97-2003 .doc; exports PDF
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens Word 97-2003 .doc; exports PDF
with real, selectable text, links and bookmarks.
- **Fast.** Paragraph layout is cached, so typing in a 188-page document re-lays it out in about
1.4 ms; pages render on demand.
@@ -122,10 +115,10 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **Pages** | Margins, orientation, size, columns, page/column/section breaks, headers and footers (first page, odd/even), page numbers, watermark, page borders, line numbers, vertical alignment, drop caps, automatic hyphenation |
| **Objects** | Pictures (resize, crop, recolour, brightness/contrast, transparency, background removal, picture styles, rotate), shapes, text boxes, floating position with text wrapping (square, top and bottom, behind or in front of text) |
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski or Slovenščina (follows the system language by default) |
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español or Українська (follows the system language by default) |
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
[parity assessment](docs/target-app-parity.md) and the generated
@@ -148,7 +141,6 @@ wordcraft-cli text report.docx # plain text
wordcraft-cli inspect report.docx # structure as JSON
wordcraft-cli run --template sample \
--cmd 'select.text={"text":"Membership"}' --cmd format.bold --save out.docx
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
```
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
@@ -159,7 +151,6 @@ WordCraft was designed to be driven by people *and* by AI agents.
```sh
claude mcp add wordcraft -- wordcraft-cli mcp # headless documents
# confine the agent to folders: wordcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
wordcraft --control 7981 & # or drive the running app…
claude mcp add wordcraft-app -- wordcraft-cli mcp --connect 127.0.0.1:7981
```
@@ -196,10 +187,10 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
| L0 | `wordcraft-geom` | units and measurements |
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
| L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) |
| L4 | `wordcraft-engine` | session, undo, 422 commands, document feature catalog |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF (krilla) |
| L4 | `wordcraft-engine` | session, undo, 422 commands, Word feature catalog |
| L5 | `wordcraft-mcp` | MCP server |
| L6 | `wordcraft-ui-egui` | the word processor front end (swappable) |
| L6 | `wordcraft-ui-egui` | the Word-style front end (swappable) |
| apps | `wordcraft`, `wordcraft-cli`, `wordcraft-web` | desktop, command line, browser |
`cargo xtask ci` runs formatting, clippy, ~800 tests, the asset-attribution check, the layering
@@ -207,13 +198,12 @@ check and the wasm build. Contributor and agent instructions: [AGENTS.md](AGENTS
## Roadmap
WordCraft is in **alpha**. It has commands for 97% of the ribbon and menu features in its feature catalog
([`docs/parity-checklist.md`](docs/parity-checklist.md)) today; counting
depth, file fidelity, stability and the rest, we estimate it is about 60% ready for real work,
about 55% for a typical professional's weekly work and about 63% for everyday essentials. Beta
needs testing against real-world .docx files, keeping charts and SmartArt through a round trip,
closer pagination fidelity, a stability sweep and native printing. Details and estimates:
[ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
WordCraft is in **alpha**. It covers 88% of Word's ribbon features with commands today; counting
depth, file fidelity, stability and the rest, we estimate it is about 60% of the way to replacing
all of Word for real work, about 55% for a typical professional's weekly work and about 63% for
everyday essentials. Beta needs testing against real-world .docx files, keeping charts and SmartArt
through a round trip, pagination closer to Word's, a stability sweep and native printing. Details
and estimates: [ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
## Downloads
@@ -221,16 +211,13 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
### Windows
| Build | Installer (all users) | Installer (just me) | Portable |
|---|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-user-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-user-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-user-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
The all-users installer puts WordCraft in Program Files and needs administrator rights. The
just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your
account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two
in the Start menu. Installers and executables are code-signed.
Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
@@ -247,22 +234,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
The **32-bit** build uses OpenGL even when a DirectX 12 graphics card is present. On 32-bit
Windows, creating a DirectX 12 graphics pipeline fails immediately and the app exits before the
window appears. `WGPU_BACKEND=vulkan` (or `dx12`) still overrides that.
**If the app quits right after its window appears with "Parent device is lost" in the log:**
WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows, so it needs no
`dxcompiler.dll`/`dxil.dll` and ignores any copy another program put on `PATH` (#603).
`WGPU_DX12_COMPILER` (`fxc`, `dxc`, `auto`) overrides this; with `dxc`, a matching
`dxcompiler.dll` *and* `dxil.dll` must sit next to `wordcraft.exe`. `WGPU_BACKEND = "vulkan"`
also avoids DirectX 12 altogether.
**If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers the built-in
software renderer, which draws every frame on the processor. WordCraft normally uses OpenGL instead,
without setting anything. If OpenGL has no usable adapter, it can use DirectX 12's WARP software
adapter. The log file records the choice (lines starting with
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's
software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL
instead, without setting anything. The log file records the choice (lines starting with
`graphics:`). `WGPU_BACKEND` still overrides it, e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
### macOS
@@ -282,41 +257,6 @@ adapter. The log file records the choice (lines starting with
| Fedora/RHEL/openSUSE | `wordcraft-<ver>-linux-x86_64.rpm` | `wordcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `wordcraft-<ver>-linux-x86_64.tar.gz` | `wordcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
(Ubuntu 24.04, Debian 13 and newer).
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
winit (the windowing library) supports dragging files into the window only on X11. To use the
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
```sh
WORDCRAFT_WAYLAND=1 wordcraft
```
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
to the AUR itself. Install one of them:
| Package | What it installs | Choose it when |
|---|---|---|
| [`wordcraft-bin`](https://aur.archlinux.org/packages/wordcraft-bin) | The release tarball above, prebuilt (x86_64, aarch64) | You want the latest release without compiling (quickest) |
| [`wordcraft`](https://aur.archlinux.org/packages/wordcraft) | The latest release, built from source with `cargo` | You prefer to build releases yourself |
| [`wordcraft-git`](https://aur.archlinux.org/packages/wordcraft-git) | The current `main` branch, built from source | You want unreleased changes and accept an occasional broken build |
For example `yay -S wordcraft-bin` or `paru -S wordcraft-bin`.
#### Community packages
These packages are maintained by community members, not by the WordCraft team.
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the
Linux release as `app-office/wordcraft-bin` (prebuilt) and `app-office/wordcraft` (built from source):
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'app-office/wordcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/wordcraft
emerge --ask app-office/wordcraft-bin
```
### FreeBSD
| Build | File |
@@ -378,18 +318,16 @@ Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. Required notice
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
The English spelling dictionary and hyphenation come from Grady Ward's public-domain Moby
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled), as
do Russian, Portuguese, Czech, Italian, Croatian and Turkish spelling (how to install them:
[docs/spelling-dictionaries.md](docs/spelling-dictionaries.md)), and
Polish hyphenation the TeX patterns by Hanna Kołodziejska, Bogusław Jackowski and Marek Ryćko (MIT
option). The sample documents and templates are original text written for WordCraft.
The spelling dictionary and hyphenation come from Grady Ward's public-domain Moby Hyphenator II
word list. The sample documents and templates are original text written for WordCraft.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and WordCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Microsoft and Microsoft Word are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. WordCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+15 -867
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -20,4 +20,3 @@ wordcraft-render = { workspace = true }
wordcraft-mcp = { workspace = true }
wordcraft-zotero = { workspace = true }
serde_json = { workspace = true }
sys-locale = "0.3"
-90
View File
@@ -1,90 +0,0 @@
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
//! flag must not silently remove a root and leave the server unconfined.
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
let mut args = ["a folder".to_owned(), "report.docx".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--author", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("report.docx"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
}
File diff suppressed because it is too large Load Diff
-112
View File
@@ -1,112 +0,0 @@
//! `run --script FILE`: commands read from a JSON file (`-` is stdin), so a job can be kept in a
//! file instead of a long chain of `--cmd` flags (#1963).
//!
//! ```json
//! {"file": "in.docx", "template": "sample",
//! "commands": [{"command": "text.insert", "params": {"text": "Hi"}}, "caret.docEnd"],
//! "save": ["out.docx", "out.pdf"]}
//! ```
//!
//! `file` and `template` are optional (`--file` / `--template` on the command line win), and a
//! command is an object with `command` and optional `params`, or just the id as a string. Relative
//! `file` and `save` paths resolve against the script's folder (the working folder for stdin).
use std::io::Read;
use std::path::Path;
use serde_json::Value;
/// The largest script read. A job is a list of commands; anything bigger is a mistake.
pub const MAX_SCRIPT_BYTES: u64 = 4 * 1024 * 1024;
/// The most commands a script may hold.
const MAX_COMMANDS: usize = 100_000;
/// The most outputs a script may save to.
const MAX_SAVES: usize = 64;
#[derive(Debug)]
pub struct Script {
pub file: Option<String>,
pub template: Option<String>,
pub commands: Vec<(String, Value)>,
pub save: Vec<String>,
}
/// Read the script at `arg` (`-` for stdin) and parse it.
pub fn load(arg: &Path) -> Result<Script, String> {
let name = arg.display().to_string();
if arg == Path::new("-") {
return load_from(std::io::stdin().lock(), &name, None);
}
let f = std::fs::File::open(arg).map_err(|e| format!("--script {name}: {e}"))?;
load_from(f, &name, arg.parent().filter(|p| !p.as_os_str().is_empty()))
}
/// Read and parse a script from `r`, called `name` in errors; relative paths resolve against `base`.
pub fn load_from(r: impl Read, name: &str, base: Option<&Path>) -> Result<Script, String> {
let text = read_capped(r, name)?;
parse(&text, base).map_err(|e| format!("--script {name}: {e}"))
}
fn read_capped(r: impl Read, name: &str) -> Result<String, String> {
let mut bytes = Vec::new();
r.take(MAX_SCRIPT_BYTES + 1).read_to_end(&mut bytes).map_err(|e| format!("--script {name}: {e}"))?;
if bytes.len() as u64 > MAX_SCRIPT_BYTES {
return Err(format!("--script {name}: script too large (over {} MiB)", MAX_SCRIPT_BYTES / 1024 / 1024));
}
String::from_utf8(bytes).map_err(|_| format!("--script {name}: not valid UTF-8"))
}
fn resolve(path: &str, base: Option<&Path>) -> String {
match base {
Some(b) if Path::new(path).is_relative() => b.join(path).to_string_lossy().into_owned(),
_ => path.to_string(),
}
}
fn opt_string(obj: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, String> {
match obj.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(format!("`{key}` must be a string")),
}
}
pub fn parse(text: &str, base: Option<&Path>) -> Result<Script, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("bad JSON: {e}"))?;
let obj = v.as_object().ok_or("a script is a JSON object with a `commands` list")?;
let list = match obj.get("commands") {
Some(Value::Array(a)) => a,
_ => return Err("`commands` must be a list".into()),
};
if list.is_empty() {
return Err("`commands` is empty".into());
}
if list.len() > MAX_COMMANDS {
return Err(format!("too many commands (over {MAX_COMMANDS})"));
}
let mut commands = Vec::with_capacity(list.len());
for (i, c) in list.iter().enumerate() {
let n = i + 1;
let entry = match c {
Value::String(id) => Some((id.clone(), Value::Object(Default::default()))),
Value::Object(o) => match o.get("command") {
Some(Value::String(id)) => Some((id.clone(), o.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default())))),
_ => None,
},
_ => None,
};
match entry {
Some((id, _)) if id.is_empty() => return Err(format!("command {n}: empty command id")),
Some(e) => commands.push(e),
None => return Err(format!("command {n}: expected a command id or {{\"command\": id, \"params\": {{…}}}}")),
}
}
let save = match obj.get("save") {
None | Some(Value::Null) => Vec::new(),
Some(Value::String(s)) => vec![resolve(s, base)],
Some(Value::Array(a)) if a.len() <= MAX_SAVES => {
a.iter().map(|s| s.as_str().map(|s| resolve(s, base)).ok_or("`save` entries must be strings")).collect::<Result<_, _>>()?
}
Some(_) => return Err(format!("`save` must be a path or a list of at most {MAX_SAVES} paths")),
};
Ok(Script { file: opt_string(obj, "file")?.map(|f| resolve(&f, base)), template: opt_string(obj, "template")?, commands, save })
}
-39
View File
@@ -1,39 +0,0 @@
//! `wordcraft-cli run` with documents opened along the way: `file.new` and `file.open` open
//! another document and make it active, and `--save` saves the active one.
use std::path::Path;
use std::process::{Command, Output};
fn run(args: &[&str], out: &Path) -> Output {
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cli.arg("run").args(args).arg("--save").arg(out);
cli.output().unwrap()
}
#[test]
fn run_saves_the_document_opened_last() {
let dir = std::env::temp_dir().join(format!("wordcraft-cli-documents-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (src, out) = (dir.join("opened.txt"), dir.join("out.txt"));
std::fs::write(&src, "Opened\n").unwrap();
let open = format!(r#"file.open={{"path":{}}}"#, serde_json::json!(src.to_string_lossy()));
// A script that starts a new document and types into it saves that document.
let made = run(&["--template", "sample", "--cmd", "file.new", "--cmd", r#"text.insert={"text":"Fresh"}"#], &out);
let fresh = std::fs::read_to_string(&out).unwrap_or_default();
// A file opened after typing is what is saved; the typed document stays out of it.
let opened = run(&["--template", "blank", "--cmd", r#"text.insert={"text":"Typed"}"#, "--cmd", &open, "--cmd", "caret.docEnd"], &out);
let reopened = std::fs::read_to_string(&out).unwrap_or_default();
// A run starts with one open document.
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
let listed = cli.args(["run", "--template", "sample", "--print", "--cmd", "view.switchWindows"]).output().unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(made.status.success(), "stderr: {}", String::from_utf8_lossy(&made.stderr));
assert_eq!(fresh.trim(), "Fresh");
assert!(opened.status.success(), "stderr: {}", String::from_utf8_lossy(&opened.stderr));
assert_eq!(reopened.trim(), "Opened");
assert!(listed.status.success(), "stderr: {}", String::from_utf8_lossy(&listed.stderr));
let stdout = String::from_utf8_lossy(&listed.stdout);
assert_eq!(stdout.matches("\"id\"").count(), 1, "{stdout}");
}
@@ -1,37 +0,0 @@
//! A reader that closes stdout early (`wordcraft-cli commands --json | head`) must end the CLI
//! quietly with exit 0, never with a panic (#508).
use std::io::{Read, Write};
use std::process::{Command, Stdio};
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"))
}
#[test]
fn commands_json_into_a_closed_pipe_exits_quietly() {
let mut child = cli().args(["commands", "--json"]).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
// The listing is far larger than a pipe buffer: read a little, then hang up like `head -c`.
let mut stdout = child.stdout.take().unwrap();
let mut first = [0u8; 16];
stdout.read_exact(&mut first).unwrap();
drop(stdout);
let out = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(!stderr.contains("panicked"), "stderr: {stderr}");
}
#[test]
fn mcp_server_ends_cleanly_when_its_client_closes_stdout() {
let mut child = cli().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
let mut stdin = child.stdin.take().unwrap();
// The reply to this request has nowhere to go.
let _ = stdin.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n");
drop(stdin);
let out = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(!stderr.contains("panicked"), "stderr: {stderr}");
}
@@ -1,49 +0,0 @@
//! Explicit field refresh for headless conversions; ordinary conversion keeps cached results.
use std::process::Command;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::{Document, Path, Pos, StoryRef};
#[test]
fn convert_refresh_is_opt_in() {
let dir = std::env::temp_dir().join(format!("wc-convert-fields-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut doc = Document::from_text("Contents\nTarget");
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
for locked in [true, false] {
doc.insert_object(
&Pos::body(0, 0),
InlineObject::Field { instr: "PAGEREF target".into(), result: "104".into(), locked, dirty: true, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
}
let input = dir.join("in.json");
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let docx = dir.join("in.docx");
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
for refresh in [false, true] {
let output = dir.join(if refresh { "refreshed.json" } else { "cached.json" });
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(&docx).arg(&output);
if refresh {
cmd.arg("--update-fields");
}
let run = cmd.output().unwrap();
assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr));
let out: Document = serde_json::from_slice(&std::fs::read(output).unwrap()).unwrap();
let results: Vec<_> = out
.para(StoryRef::Body, &Path::top(0))
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.as_str()),
_ => None,
})
.collect();
assert_eq!(results, if refresh { vec!["2", "104"] } else { vec!["104", "104"] });
}
std::fs::remove_dir_all(dir).unwrap();
}
@@ -1,54 +0,0 @@
//! `wordcraft-cli convert page.html out.docx` puts an HTML file, which gives no page size, on the
//! system region's paper, as the app does: A4 in Italy, Letter in the US (#1381).
use std::process::Command;
const HTML: &str = "<html><body><h2>Premessa</h2><table border=1><thead><tr><th>Indicatore</th><th>Valore</th></tr></thead>\
<tbody><tr><td>Audit</td><td style=\"text-align:right;\">4</td></tr></tbody></table></body></html>";
/// Run the CLI in `region` with `args` (`{src}` is the sample HTML, `{out}` a JSON file it
/// writes) and read back the page size of the JSON it saved.
fn page_size_of(region: &str, args: &[&str]) -> (f64, f64) {
let dir = std::env::temp_dir().join(format!("wordcraft-cli-region-{region}-{}-{}", args.len(), std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (src, out) = (dir.join("page.html"), dir.join("out.json"));
std::fs::write(&src, HTML).unwrap();
let args: Vec<String> = args.iter().map(|a| a.replace("{src}", &src.to_string_lossy()).replace("{out}", &out.to_string_lossy())).collect();
let run = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(&args).env("WORDCRAFT_REGION", region).env_remove("LC_ALL").output().unwrap();
assert!(run.status.success(), "{args:?}: {}", String::from_utf8_lossy(&run.stderr));
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let sp = &doc["lastSection"];
(sp["pageW"].as_f64().unwrap(), sp["pageH"].as_f64().unwrap())
}
/// Convert the sample to JSON in `region` and read back the page size.
fn page_size(region: &str) -> (f64, f64) {
page_size_of(region, &["convert", "{src}", "{out}"])
}
fn is_a4((w, h): (f64, f64)) -> bool {
(w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01
}
#[test]
fn html_converts_onto_the_region_paper() {
let (w, h) = page_size("it-IT");
assert!(is_a4((w, h)), "A4 in Italy: {w} x {h}");
assert_eq!(page_size("en-US"), (612.0, 792.0), "Letter in the US");
}
#[test]
fn every_run_session_follows_the_region() {
// `run --file`, `run --template` and a bare `run` (with or without `file.new`) all start a session in the system region,
// so `file.new` gives the same paper whichever way the session began.
for args in [
&["run", "--cmd", "file.new", "--save", "{out}"][..],
&["run", "--cmd", "text.insert={\"text\":\"x\"}", "--save", "{out}"][..],
&["run", "--template", "blank", "--save", "{out}"][..],
&["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..],
] {
assert!(is_a4(page_size_of("it-IT", args)), "A4 in Italy: {args:?} gave {:?}", page_size_of("it-IT", args));
assert_eq!(page_size_of("en-US", args), (612.0, 792.0), "Letter in the US: {args:?}");
}
}
-47
View File
@@ -1,47 +0,0 @@
//! `--help` or `-h` after any subcommand prints the usage and exits 0, before options are checked
//! or files and folders opened (#968).
use std::process::{Command, Output};
const SUBCOMMANDS: [&str; 10] = ["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
fn cli(args: &[&str]) -> Output {
// `output()` gives the child no stdin, so `mcp` can't wait for a client.
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
}
fn assert_prints_usage(args: &[&str]) {
let out = cli(args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{args:?}: status {:?}, stderr: {stderr}", out.status);
assert!(String::from_utf8_lossy(&out.stdout).starts_with("wordcraft-cli — "), "{args:?}: stdout: {}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.is_empty(), "{args:?}: stderr: {stderr}");
}
#[test]
fn every_subcommand_prints_usage_for_help_and_h() {
assert_prints_usage(&["--help"]);
assert_prints_usage(&["-h"]);
for sub in SUBCOMMANDS {
for flag in ["--help", "-h"] {
assert_prints_usage(&[sub, flag]);
}
}
}
#[test]
fn help_comes_before_opening_files_and_folders() {
assert_prints_usage(&["info", "missing.docx", "--help"]);
assert_prints_usage(&["convert", "missing.docx", "out.pdf", "-h"]);
assert_prints_usage(&["mcp", "--automation-read-root", "/definitely/not/a/folder", "--help"]);
}
#[test]
fn misspelled_options_and_unknown_commands_still_fail() {
let out = cli(&["info", "--hepl"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("info: unknown option --hepl"));
let out = cli(&["frobnicate", "--help"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command `frobnicate`"));
}
-9
View File
@@ -1,9 +0,0 @@
#[cfg(unix)]
mod closed_stdout;
mod convert_fields;
mod convert_region;
mod help;
#[cfg(all(unix, not(target_os = "macos")))]
mod non_utf8_args;
mod picture_then_typing;
mod text_tracked;
@@ -1,75 +0,0 @@
//! A file name that isn't valid Unicode (`caf\xe9.txt`, Latin-1) never panics the CLI (#1440): the
//! file a command opens opens, and any other such argument is an error naming it. Not on macOS:
//! APFS refuses file names that aren't valid UTF-8, so the fixture can't be made there.
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::PathBuf;
use std::process::{Command, Output};
fn cli(args: &[OsString]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
}
/// A temp folder holding `caf\xe9.txt`, whose text is "Bonjour".
fn folder(name: &str) -> (PathBuf, OsString) {
let dir = std::env::temp_dir().join(format!("wc-cli-non-utf8-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join(OsString::from_vec(b"caf\xe9.txt".to_vec()));
std::fs::write(&file, "Bonjour\n").unwrap();
(dir, file.into_os_string())
}
fn ok(out: &Output) -> String {
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn the_file_a_command_opens_may_have_any_name() {
let (dir, file) = folder("open");
assert_eq!(ok(&cli(&["text".into(), file.clone()])).trim(), "Bonjour");
assert!(ok(&cli(&["info".into(), file.clone()])).contains("\"words\""));
assert!(ok(&cli(&["inspect".into(), file.clone()])).contains("Bonjour"));
let md = dir.join("out.md");
ok(&cli(&["convert".into(), file.clone(), md.clone().into()]));
assert!(std::fs::read_to_string(&md).unwrap().contains("Bonjour"));
let saved = dir.join("run.txt");
ok(&cli(&["run".into(), "--file".into(), file.clone(), "--save".into(), saved.clone().into()]));
assert!(std::fs::read_to_string(&saved).unwrap().contains("Bonjour"));
// Saving without a path writes back to the file under its real name, not a lossy one.
let mut args: Vec<OsString> = vec!["run".into(), "--file".into(), file.clone()];
for cmd in ["caret.docEnd", "text.insert={\"text\":\" encore\"}", "file.save"] {
args.extend(["--cmd".into(), cmd.into()]);
}
ok(&cli(&args));
assert!(std::fs::read_to_string(&file).unwrap().contains("encore"));
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "a second file was written");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn any_other_such_argument_is_an_error_naming_it() {
let (dir, file) = folder("error");
let out_name = dir.join(OsString::from_vec(b"caf\xe9.md".to_vec())).into_os_string();
let cmd = OsString::from_vec(b"text.insert={\"text\":\"\xe9\"}".to_vec());
for (args, n) in [
(vec!["convert".into(), file.clone(), out_name.clone()], 3),
(vec!["run".into(), "--file".into(), file.clone(), "--cmd".into(), cmd], 5),
(vec![OsString::from_vec(b"t\xe9xt".to_vec()), file.clone()], 1),
] {
let out = cli(&args);
let stderr = String::from_utf8_lossy(&out.stderr);
let shown = args[n - 1].to_string_lossy();
assert!(shown.contains('\u{FFFD}'));
assert_eq!(out.status.code(), Some(1), "{args:?}: stderr: {stderr}");
assert_eq!(stderr, format!("wordcraft-cli: argument {n} is not valid Unicode: {shown}\n"), "{args:?}");
}
assert!(!std::path::Path::new(&out_name).exists());
let _ = std::fs::remove_dir_all(dir);
}
@@ -1,31 +0,0 @@
//! `wordcraft-cli run`: a picture inserted by `--cmd insert.picture` stays in the saved file when
//! the next commands type (#965).
use std::process::Command;
/// A 1x1 PNG.
const PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[test]
fn typing_after_insert_picture_keeps_it_in_the_saved_file() {
let dir = std::env::temp_dir().join(format!("wordcraft-cli-965-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (pic, out) = (dir.join("pic.png"), dir.join("p.docx"));
std::fs::write(&pic, PNG).unwrap();
let picture = format!(r#"insert.picture={{"path":{},"width":250}}"#, serde_json::json!(pic.to_string_lossy()));
let cmds = [r#"text.insert={"text":"x"}"#, "text.newParagraph", &picture, "text.newParagraph", r#"text.insert={"text":"cap"}"#];
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cli.args(["run", "--template", "blank"]);
for c in cmds {
cli.args(["--cmd", c]);
}
let run = cli.arg("--save").arg(&out).output().unwrap();
let docx = std::fs::read(&out);
let _ = std::fs::remove_dir_all(&dir);
assert!(run.status.success(), "stderr: {}", String::from_utf8_lossy(&run.stderr));
assert!(docx.unwrap().windows(11).any(|w| w == b"word/media/"), "the saved file keeps the picture");
}
@@ -1,42 +0,0 @@
//! #866: `wordcraft-cli text` omits tracked deletions (it does not go through `document.text`).
use std::process::Command;
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"))
}
#[test]
fn text_omits_tracked_deletions() {
let dir = std::env::temp_dir().join(format!("wc-cli-866-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("t.docx");
let path_s = path.to_str().unwrap();
let make = cli()
.args([
"run",
"--save",
path_s,
"--cmd",
r#"text.insert={"text":"Signed by Alice Smith, Partner."}"#,
"--cmd",
r#"review.trackChanges={"value":true}"#,
"--cmd",
r#"select.text={"text":"Alice Smith, Partner"}"#,
"--cmd",
r#"text.insert={"text":"Bob Jones, Director"}"#,
])
.output()
.expect("run");
assert!(make.status.success(), "run failed: {}", String::from_utf8_lossy(&make.stderr));
let out = cli().args(["text", path_s]).output().expect("text");
assert!(out.status.success(), "text failed: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.trim_end(), "Signed by Bob Jones, Director.");
assert!(!stdout.contains("Alice Smith"), "{stdout}");
let _ = std::fs::remove_dir_all(&dir);
}
+1 -2
View File
@@ -20,7 +20,6 @@ egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
wordcraft-doc = { workspace = true }
wordcraft-fonts = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
@@ -29,4 +28,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File"] }
-1
View File
@@ -4,7 +4,6 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>WordCraft</title>
<link data-trunk rel="icon" href="../../assets/app-icon/wordcraft.ico" />
<link data-trunk rel="rust" data-bin="wordcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e6e6e6; }
-169
View File
@@ -1,169 +0,0 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file WordCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the document's file:
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
//! .docx) is a new file: `true`, as for Save As.
//! - Save As, and File › Export to another document format (which runs Save As): `true` for a
//! format that keeps everything (.docx and the other Word packages, .odt, .rtf). The document
//! is now that file; the next Save sends its name with `false`.
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the document showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// changes to it, and hear the files it sends.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Anything else that would download goes to the parent too.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
// The browser's save picker is never used, so no save finishes later.
services.async_saves = None;
let bridge = self.clone();
let reported: Rc<Cell<Option<bool>>> = Rc::default();
services.on_dirty = Some(Box::new(move |dirty: bool| {
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.set(Some(dirty));
}
}));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
pub fn set_author(app: &mut WordApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.author = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "wordcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("wordcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
}
-11
View File
@@ -6,20 +6,9 @@
//!
//! Differences from the desktop app: no TCP control channel; Open and Insert › Pictures use the
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
//! comments and tracked changes.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod web;
-129
View File
@@ -1,129 +0,0 @@
//! Preferences between visits (#2132): the same [`UiState`] the desktop app keeps in `ui.json`
//! (theme, interface size, recent symbols, editing options…), kept in the browser's
//! `localStorage` under [`KEY`]. Restored at startup; written back whenever it changes, so a
//! reload or a closed tab keeps the last setting (browsers don't reliably run exit code).
use wordcraft_ui_egui::{UiState, WordApp};
/// The `localStorage` key.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub const KEY: &str = "wordcraft.ui";
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
const MAX_STORED: usize = 4 << 20;
/// Apply preferences stored on an earlier visit. Missing, oversized or unreadable values leave
/// the defaults.
pub fn restore(app: &mut WordApp, stored: Option<&str>) {
if let Some(s) = stored.filter(|s| s.len() <= MAX_STORED)
&& let Some(ui) = UiState::from_json(s.as_bytes())
{
app.apply_prefs(ui);
}
}
/// Tracks what was last written, so the store is written only when a preference changed.
#[derive(Default)]
pub struct PrefsSync {
last: Option<String>,
/// A host page's name for the user (`?author=`) and the user's own, which is stored in its
/// place while the user keeps the host's.
host_author: Option<(String, String)>,
}
impl PrefsSync {
/// Starts from what is already stored, so restoring doesn't write it straight back.
pub fn new(stored: Option<String>) -> Self {
Self { last: stored, host_author: None }
}
/// The session signs with `host`, a host page's name for the user, but `own` stays the
/// stored name. A name the user then chooses in File › Options is stored as usual.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn keep_own_author(&mut self, host: &str, own: &str) {
if host != own {
self.host_author = Some((host.to_string(), own.to_string()));
}
}
/// The JSON to store when the app's preferences differ from what was last written.
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
let mut prefs = app.prefs();
if let Some((host, own)) = &self.host_author
&& prefs.author == *host
{
prefs.author = own.clone();
}
let json = match serde_json::to_string(&prefs) {
Ok(j) => j,
Err(e) => {
log::warn!("preferences: cannot serialize: {e}");
return None;
}
};
if self.last.as_deref() == Some(json.as_str()) {
return None;
}
self.last = Some(json.clone());
Some(json)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wordcraft_ui_egui::Services;
fn app() -> WordApp {
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
}
/// A host page's name for the user signs the visit but isn't stored; a name the user then
/// chooses is.
#[test]
fn host_author_is_not_stored() {
let mut a = app();
a.session.author = "Grace Hopper".into();
let mut sync = PrefsSync::new(None);
let stored = sync.changed(&a).unwrap();
a.session.author = "rob".into();
sync.keep_own_author("rob", "Grace Hopper");
a.run("ui.theme", json!({"value": "dark"})).unwrap();
let json = sync.changed(&a).expect("the theme changed");
assert_ne!(json, stored);
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Grace Hopper");
a.session.author = "Rob Hones".into();
let json = sync.changed(&a).expect("the user chose a name");
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Rob Hones");
}
/// #2132: dark mode chosen on one visit is there after a reload.
#[test]
fn dark_mode_survives_reload() {
let mut first = app();
let mut sync = PrefsSync::new(None);
let initial = sync.changed(&first);
assert!(initial.is_some(), "first frame stores the preferences");
assert_eq!(sync.changed(&first), None, "unchanged preferences are not rewritten");
first.run("ui.theme", json!({"value": "dark"})).unwrap();
let stored = sync.changed(&first).expect("the theme change is stored");
let mut reloaded = app();
restore(&mut reloaded, Some(&stored));
assert_eq!(reloaded.ui.theme, wordcraft_ui_egui::theme::Appearance::Dark);
assert_eq!(PrefsSync::new(Some(stored)).changed(&reloaded), None, "restoring doesn't write back");
}
#[test]
fn junk_storage_keeps_defaults() {
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"theme\":42}")] {
let mut a = app();
restore(&mut a, junk);
assert_eq!(a.ui.theme, wordcraft_ui_egui::theme::Appearance::System, "junk keeps the default appearance");
}
}
}
-46
View File
@@ -1,46 +0,0 @@
//! Save-picker rejection policy, shared with native tests of the browser fallback.
use wordcraft_ui_egui::async_saves::SaveOutcome;
/// A cancelled picker ends the save. Other picker failures try the browser download path.
/// Write/close errors do not pass through this function: they retain their own failure.
pub(crate) fn picker_rejected(name: &str, download: impl FnOnce() -> Result<(), String>) -> SaveOutcome {
if name == "AbortError" {
SaveOutcome::Cancelled
} else {
match download() {
Ok(()) => SaveOutcome::Saved,
Err(error) => SaveOutcome::Failed(error),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unavailable_picker_downloads_once() {
for name in ["SecurityError", "NotSupportedError", "TypeError", ""] {
let mut calls = 0;
let outcome = picker_rejected(name, || {
calls += 1;
Ok(())
});
assert_eq!(outcome, SaveOutcome::Saved);
assert_eq!(calls, 1);
}
}
#[test]
fn cancelled_picker_never_downloads() {
let outcome = picker_rejected("AbortError", || panic!("cancel must not download"));
assert_eq!(outcome, SaveOutcome::Cancelled);
}
#[test]
fn failed_fallback_remains_a_failed_save() {
let outcome = picker_rejected("SecurityError", || Err("download unavailable".into()));
assert_eq!(outcome, SaveOutcome::Failed("download unavailable".into()));
}
}
+43 -397
View File
@@ -1,18 +1,16 @@
//! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe
//! web runner.
use std::cell::{Cell, RefCell};
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wordcraft_engine::Session;
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
mod font_store;
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt"];
const CANVAS_ID: &str = "wordcraft_canvas";
const LOADING_ID: &str = "wordcraft_loading";
@@ -27,15 +25,8 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
// what it saves (`host`). That page guards leaving it, so this one doesn't.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
if bridge.is_none()
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
{
if let Err(e) = guard_unload(dirty.clone()) {
log::error!("no unsaved-changes guard: {e}");
}
let mut options = eframe::WebOptions::default();
@@ -52,64 +43,17 @@ pub fn start() {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
// Fonts the user added on an earlier visit (#582); the app notices them arrive.
font_store::load_all(cc.egui_ctx.clone());
let inbox: Inbox = Inbox::default();
let pasted: Inbox = Inbox::default();
if let Err(e) = listen_for_pasted_pictures(pasted.clone(), cc.egui_ctx.clone()) {
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
log::error!("pasting pictures is unavailable: {e}");
}
// The browser's locale picks the new document's paper, the units and the
// decimal separator (#838).
let region = wordcraft_ui_egui::i18n::system_region();
// In host mode the parent sends the document to edit; no sample meanwhile.
let sample = app_bridge.is_none() && query().contains("sample");
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let mut session = Session::new(doc);
session.system_region = region;
let messages = Rc::new(RefCell::new(Vec::new()));
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let mut app = WordApp::new(session, services);
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty));
app.autosave = false;
// Preferences from an earlier visit (#2132), as the desktop app reads `ui.json`.
let stored = stored_prefs();
crate::prefs::restore(&mut app, stored.as_deref());
// A region chosen in File › Options replaces the browser's for the first blank
// document too (#838).
if !sample && app.session.prefs.region.is_some() {
let doc = app.session.blank_document();
app.session.set_document(doc);
}
let mut prefs = crate::prefs::PrefsSync::new(stored);
// No document to show: start on File › Home, as the desktop app does (#766).
if !sample {
app.start_screen();
}
// The host's name for the user signs this visit's work, but isn't stored as the
// browser's own preference, which a visit outside the host would restore.
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
let own = app.session.author.clone();
crate::host::Bridge::set_author(&mut app, &author);
prefs.keep_own_author(&app.session.author, &own);
}
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
Ok(Box::new(WebShell { app, inbox }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -123,79 +67,38 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
app: WordApp,
inbox: Inbox,
messages: Rc<RefCell<Vec<String>>>,
prefs: crate::prefs::PrefsSync,
}
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok()?
}
/// The preferences stored on an earlier visit, if the browser allows storage.
fn stored_prefs() -> Option<String> {
local_storage()?.get_item(crate::prefs::KEY).ok()?
}
impl eframe::App for WebShell {
/// Runs one browser frame: forwards queued host messages to the status bar, advances the app,
/// then writes changed preferences to browser storage immediately, because a reload or closed
/// tab never runs the exit path.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
for msg in msgs {
self.app.status(msg);
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
self.app.logic(ctx);
// Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).
if let Some(json) = self.prefs.changed(&self.app) {
match local_storage() {
Some(store) => {
if let Err(e) = store.set_item(crate::prefs::KEY, &json) {
log::warn!("preferences: cannot save: {e:?}");
}
}
None => log::warn!("preferences: browser storage is unavailable"),
}
}
}
/// Forwards this frame's raw browser input to the app before widgets run.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
}
/// Draws the app. On the first pass of a visible frame it takes the browser's dropped files and
/// spawns asynchronous byte reads into the inbox; later passes only draw.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Browser drops are input: take them only in the first real UI pass, never in a hidden
// `run_logic` tick that still sees the previous shown frame's RawInput.
if ui.ctx().current_pass_index() == 0 {
let ctx = ui.ctx().clone();
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
self.app.ui(ui);
}
}
@@ -207,11 +110,10 @@ const MAX_PASTED_PICTURES: u32 = 20;
/// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just
/// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through
/// the app's `pasted` queue, which inserts them only when the page is the edit target (#1349). A clipboard with text is left to eframe.
/// the inbox like dropped ones. A clipboard with text is left to eframe.
fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> {
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
// The target is always eframe's hidden text input while the page has focus; the app decides (#1349).
let Some(data) = e.clipboard_data() else { return };
if !data.get_data("text").unwrap_or_default().is_empty() {
return;
@@ -232,7 +134,7 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => {
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
// The engine reads the format from the bytes; the name is ignored.
// The inbox inserts files named like pictures; the engine reads the format from the bytes.
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes));
ctx.request_repaint();
}
@@ -248,178 +150,44 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
}
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: AsyncSaves, messages: Rc<RefCell<Vec<String>>>) -> Services {
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services {
let open_inbox = inbox.clone();
let save_ctx = ctx.clone();
let font_ctx = ctx.clone();
let font_messages = messages.clone();
Services {
open_async: Some(Box::new(move |purpose: &str| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = if purpose == "picture" {
rfd::AsyncFileDialog::new().add_filter("Pictures", IMAGE_EXTS)
} else if purpose == "font" {
rfd::AsyncFileDialog::new().add_filter("Fonts", wordcraft_fonts::FONT_FILE_EXTS)
} else if purpose == "recipients" {
rfd::AsyncFileDialog::new().add_filter("Recipient lists", RECIPIENT_EXTS)
} else {
rfd::AsyncFileDialog::new().add_filter("Documents", DOC_EXTS)
};
let many = purpose == "font";
wasm_bindgen_futures::spawn_local(async move {
// Add Font takes every selected file (#1789); the others one.
let files = if many { dialog.pick_files().await.unwrap_or_default() } else { dialog.pick_file().await.into_iter().collect() };
for file in files.into_iter().take(MAX_PICKED_FILES) {
let mut bytes = file.read().await;
let mut pending = inbox.lock().unwrap_or_else(|e| e.into_inner());
// Fonts waiting for the app to drain them stay under a byte cap. A file
// over it is queued empty, so Add Font reports that file on its own.
let held: usize = pending.iter().map(|(_, b)| b.len()).fold(0, usize::saturating_add);
if many && held.saturating_add(bytes.len()) > MAX_PENDING_PICKED_BYTES {
bytes = Vec::new();
}
pending.push((file.file_name(), bytes));
drop(pending);
ctx.request_repaint();
}
let Some(file) = dialog.pick_file().await else {
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
})),
// Exports ask for a name; the browser decides where the download goes.
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
async_saves: Some(saves.clone()),
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
download: Some(Box::new(download)),
print: Some(Box::new(print_pdf)),
inbox: Some(inbox),
on_dirty: Some(Box::new(move |d| dirty.set(d))),
animations_enabled: Some(animations_enabled()),
store_font: Some(Box::new(font_store::store)),
forget_font: Some(Box::new(font_store::forget)),
load_system_fonts: Some(Box::new(move || {
load_system_fonts(font_ctx.clone(), font_messages.clone());
})),
..Default::default()
}
}
/// Most files one multi-select pick (Add Font) reads.
const MAX_PICKED_FILES: usize = 2000;
/// Most bytes of picked fonts held in the inbox before the app drains them.
const MAX_PENDING_PICKED_BYTES: usize = 256 << 20;
/// Most local fonts one Load System Installed Fonts installs (a designer's machine has thousands).
const MAX_SYSTEM_FONTS: usize = 500;
/// Most font bytes one Load System Installed Fonts reads into memory (wasm memory is small).
const MAX_SYSTEM_FONT_BYTES: usize = 256 << 20;
/// Most local fonts looked at, installed or not (already available, unreadable, too big).
const MAX_SYSTEM_FONTS_TRIED: u32 = 4000;
/// Query the browser's Local Font Access API (`window.queryLocalFonts()`) and install the faces
/// into the global font database, up to [`MAX_SYSTEM_FONTS`] faces and [`MAX_SYSTEM_FONT_BYTES`].
fn load_system_fonts(ctx: egui::Context, messages: Rc<RefCell<Vec<String>>>) {
wasm_bindgen_futures::spawn_local(async move {
let Some(window) = web_sys::window() else {
return;
};
let query_fn = match js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("queryLocalFonts")) {
Ok(val) if val.is_function() => js_sys::Function::from(val),
_ => {
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access is not supported by your browser.").to_string());
ctx.request_repaint();
return;
}
};
let fonts_promise = match query_fn.call0(&window) {
Ok(p) => js_sys::Promise::from(p),
Err(e) => {
log::warn!("queryLocalFonts call failed: {e:?}");
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
ctx.request_repaint();
return;
}
};
let fonts_val = match wasm_bindgen_futures::JsFuture::from(fonts_promise).await {
Ok(val) => val,
Err(e) => {
log::warn!("queryLocalFonts promise rejected: {e:?}");
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
ctx.request_repaint();
return;
}
};
let font_list = js_sys::Array::from(&fonts_val);
let len = font_list.length();
let mut loaded = 0usize;
let mut total_bytes = 0usize;
for i in 0..len.min(MAX_SYSTEM_FONTS_TRIED) {
if loaded >= MAX_SYSTEM_FONTS || total_bytes >= MAX_SYSTEM_FONT_BYTES {
log::warn!("local fonts: stopped after {loaded} fonts ({total_bytes} bytes)");
break;
}
let font_data = font_list.get(i);
let Ok(blob_fn) = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("blob")) else {
continue;
};
if !blob_fn.is_function() {
continue;
}
let Ok(blob_val) = js_sys::Function::from(blob_fn).call0(&font_data) else {
continue;
};
let Ok(blob_js) = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(blob_val)).await else {
continue;
};
let Ok(blob) = blob_js.dyn_into::<web_sys::Blob>() else {
continue;
};
// Skip a face too big on its own or for what is left of the budget, before reading it.
let size = blob.size().clamp(0.0, usize::MAX as f64) as usize;
if size > wordcraft_fonts::MAX_FONT_FILE_BYTES || total_bytes.saturating_add(size) > MAX_SYSTEM_FONT_BYTES {
continue;
}
let Ok(array_buffer) = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()).await else {
continue;
};
let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
if bytes.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES {
continue;
}
total_bytes = total_bytes.saturating_add(bytes.len());
let ps_name = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("postscriptName"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_else(|| format!("local-font-{i}"));
let name = format!("{ps_name}.ttf");
match wordcraft_fonts::FontDb::global().install_font(&name, &bytes) {
Ok(_) => loaded += 1,
Err(wordcraft_fonts::UserFontError::AlreadyAvailable(_)) => {}
Err(e) => log::warn!("local font {name} skipped: {e}"),
}
}
if loaded > 0 {
let msg = wordcraft_ui_egui::i18n::fmt(wordcraft_ui_egui::i18n::t("Loaded {count} system fonts."), &[("count", &loaded.to_string())]);
messages.borrow_mut().push(msg);
} else {
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("All system fonts are already available.").to_string());
}
ctx.request_repaint();
});
}
/// Whether the system has animation effects on, as the browser passes it on: off while the
/// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The
/// query is made once and asked every frame; it follows the system's setting by itself.
fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
let reduce = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Box::new(move || reduce.as_ref().map(|query| !query.matches()))
}
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Result<(), String> {
fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
let window = web_sys::window().ok_or("no window")?;
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
// A save still in the browser's picker hasn't written anything yet.
if dirty.get() || saves.pending() > 0 {
if dirty.get() {
e.prevent_default();
// Older browsers ask only when `returnValue` is set.
e.set_return_value("unsaved changes");
@@ -431,141 +199,20 @@ fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Result<(), String>
Ok(())
}
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
fn download(saves: &AsyncSaves, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
let window = web_sys::window().ok_or("no window")?;
// On browsers supporting the File System Access API (Chromium: Chrome, Edge, etc.),
// `showSaveFilePicker` lets the user choose the save location and filename natively.
if let Ok(picker_val) = js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("showSaveFilePicker"))
&& let Ok(picker_fn) = picker_val.dyn_into::<js_sys::Function>()
{
match trigger_save_file_picker(saves, ctx, &window, &picker_fn, &name, bytes) {
Ok(()) => return Ok(()),
Err(e) => {
log::warn!("showSaveFilePicker failed: {e}; falling back to anchor download");
}
}
}
// Fallback for browsers without showSaveFilePicker (e.g. Firefox, Safari):
trigger_anchor_download(&window, &name, bytes)
}
fn trigger_save_file_picker(
saves: &AsyncSaves,
ctx: &egui::Context,
window: &web_sys::Window,
picker_fn: &js_sys::Function,
name: &str,
bytes: &[u8],
) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("docx").to_ascii_lowercase();
let dot_ext = format!(".{ext}");
let mime = mime_for(name);
let accept_obj = js_sys::Object::new();
let exts_arr = js_sys::Array::new();
exts_arr.push(&wasm_bindgen::JsValue::from_str(&dot_ext));
let _ = js_sys::Reflect::set(&accept_obj, &wasm_bindgen::JsValue::from_str(mime), &exts_arr);
// Our own neutral description: the format's name, as its extension says it.
let type_desc = match ext.as_str() {
"htm" => "HTML".to_string(),
"txt" => "Text".to_string(),
e if !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()) => e.to_ascii_uppercase(),
_ => "Document".to_string(),
};
let type_obj = js_sys::Object::new();
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("description"), &wasm_bindgen::JsValue::from_str(&type_desc));
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("accept"), &accept_obj);
let types_arr = js_sys::Array::new();
types_arr.push(&type_obj);
let picker_opts = js_sys::Object::new();
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("suggestedName"), &wasm_bindgen::JsValue::from_str(name));
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("types"), &types_arr);
// Call showSaveFilePicker synchronously while transient user activation is active:
let prom_val = picker_fn.call1(window, &picker_opts).map_err(js)?;
let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?;
let bytes_vec = bytes.to_vec();
let window = window.clone();
let saves = saves.clone();
let ctx = ctx.clone();
let name = name.to_string();
// The app holds back a Save Changes prompt's New/Open until this ticket ends (#1315).
let ticket = saves.start();
wasm_bindgen_futures::spawn_local(async move {
// The document counts as saved only once the file is written and closed.
let outcome = match wasm_bindgen_futures::JsFuture::from(prom).await {
Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await {
Ok(()) => {
log::info!("saved {name} through the save picker");
SaveOutcome::Saved
}
Err(e) => {
log::error!("couldn't write {name}: {e}");
SaveOutcome::Failed(e)
}
},
Err(e) => {
let err_name =
js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default();
// A browser can expose the picker but reject its promise (for example in
// a cross-origin iframe). Use the same download path as an absent API;
// cancelling a picker must never start an unwanted download (#1246).
crate::save_fallback::picker_rejected(&err_name, || trigger_anchor_download(&window, &name, &bytes_vec))
}
};
saves.finish(ticket, outcome);
ctx.request_repaint();
});
Ok(())
}
async fn write_file_handle(handle: &wasm_bindgen::JsValue, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let create_fn: js_sys::Function = js_sys::Reflect::get(handle, &wasm_bindgen::JsValue::from_str("createWritable"))
.map_err(js)?
.dyn_into()
.map_err(|_| "createWritable is not a function".to_string())?;
let stream_prom: js_sys::Promise = create_fn.call0(handle).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
let stream = wasm_bindgen_futures::JsFuture::from(stream_prom).await.map_err(js)?;
let write_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("write"))
.map_err(js)?
.dyn_into()
.map_err(|_| "write is not a function".to_string())?;
let uint8 = js_sys::Uint8Array::from(bytes);
let write_prom: js_sys::Promise = write_fn.call1(&stream, &uint8).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
wasm_bindgen_futures::JsFuture::from(write_prom).await.map_err(js)?;
let close_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("close"))
.map_err(js)?
.dyn_into()
.map_err(|_| "close is not a function".to_string())?;
let close_prom: js_sys::Promise = close_fn.call0(&stream).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
wasm_bindgen_futures::JsFuture::from(close_prom).await.map_err(js)?;
Ok(())
}
fn trigger_anchor_download(window: &web_sys::Window, name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(name));
opts.set_type(mime_for(&name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor".to_string())?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(name);
a.set_download(&name);
a.style().set_property("display", "none").map_err(js)?;
let body = document.body().ok_or("no body")?;
body.append_child(&a).map_err(js)?;
@@ -659,7 +306,6 @@ fn mime_for(name: &str) -> &'static str {
Some("md" | "txt") => "text/plain",
Some("tex") => "application/x-tex",
Some("json") => "application/json",
Some("xml") => "application/xml",
_ => "application/octet-stream",
}
}
-116
View File
@@ -1,116 +0,0 @@
//! Fonts the user added in the browser (#582), kept in IndexedDB (`localStorage` holds about
//! 5 MB, less than one CJK font) so they are there again after a reload. Every failure is logged
//! and skipped: a font that can't be stored still works for the session.
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use web_sys::{IdbDatabase, IdbRequest, IdbTransactionMode};
const DB_NAME: &str = "wordcraft";
const STORE: &str = "fonts";
fn js(e: JsValue) -> String {
format!("{e:?}")
}
/// Wait for an IndexedDB request; its result.
async fn done(req: &IdbRequest) -> Result<JsValue, String> {
let promise = js_sys::Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| {
let ok_req = req.clone();
let ok = Closure::once_into_js(move || {
let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED));
});
let err = Closure::once_into_js(move || {
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("IndexedDB request failed"));
});
req.set_onsuccess(Some(ok.unchecked_ref()));
req.set_onerror(Some(err.unchecked_ref()));
});
wasm_bindgen_futures::JsFuture::from(promise).await.map_err(js)
}
/// The database, with its font store made on first use.
async fn open() -> Result<IdbDatabase, String> {
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB is unavailable")?;
let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?;
let upgrade_req = req.clone();
let upgrade = Closure::<dyn FnMut()>::new(move || {
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>())
&& !db.object_store_names().contains(STORE)
&& let Err(e) = db.create_object_store(STORE)
{
log::error!("couldn't make the font store: {e:?}");
}
});
req.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
let db = done(&req).await;
req.set_onupgradeneeded(None);
drop(upgrade);
db?.dyn_into::<IdbDatabase>().map_err(js)
}
async fn store_async(name: String, bytes: Vec<u8>) -> Result<(), String> {
let db = open().await?;
let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?;
let req = store.put_with_key(&js_sys::Uint8Array::from(bytes.as_slice()), &JsValue::from_str(&name)).map_err(js)?;
done(&req).await.map(|_| ())
}
async fn forget_async(name: String) -> Result<(), String> {
let db = open().await?;
let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?;
let req = store.delete(&JsValue::from_str(&name)).map_err(js)?;
done(&req).await.map(|_| ())
}
/// Keep `bytes` (a font file as the user gave it) under `name` (its stored file name).
pub fn store(name: &str, bytes: &[u8]) {
let (name, bytes) = (name.to_string(), bytes.to_vec());
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = store_async(name.clone(), bytes).await {
log::error!("couldn't keep the font {name} for the next visit: {e}");
}
});
}
/// Forget the font stored under `name`.
pub fn forget(name: &str) {
let name = name.to_string();
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = forget_async(name.clone()).await {
log::error!("couldn't forget the stored font {name}: {e}");
}
});
}
/// Add every stored font to the font database (each checked like a new one; one that fails is
/// logged and skipped), then repaint: the app lays out again when fonts change.
pub fn load_all(ctx: egui::Context) {
wasm_bindgen_futures::spawn_local(async move {
let r: Result<(), String> = async {
let db = open().await?;
let store = db.transaction_with_str(STORE).map_err(js)?.object_store(STORE).map_err(js)?;
let keys = done(&store.get_all_keys().map_err(js)?).await?;
let values = done(&store.get_all().map_err(js)?).await?;
let (keys, values) = (js_sys::Array::from(&keys), js_sys::Array::from(&values));
for i in 0..keys.length().min(values.length()) {
let Some(name) = keys.get(i).as_string() else { continue };
let Ok(bytes) = values.get(i).dyn_into::<js_sys::Uint8Array>() else { continue };
if bytes.length() as usize > wordcraft_fonts::MAX_FONT_FILE_BYTES {
log::warn!("stored font {name} is too large; skipped");
continue;
}
if let Err(e) = wordcraft_fonts::FontDb::global().install_font(&name, &bytes.to_vec()) {
log::warn!("stored font {name} skipped: {e}");
}
}
Ok(())
}
.await;
if let Err(e) = r {
log::warn!("no stored fonts: {e}");
}
ctx.request_repaint();
});
}
+5 -46
View File
@@ -13,17 +13,13 @@ workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-engine = { workspace = true }
wordcraft-proof = { workspace = true }
# Fonts the user added (#582) load from the settings folder at start-up.
wordcraft-fonts = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The control key file's location (shared with the MCP bridge).
wordcraft-control-key = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for wordcraft.exe; build.rs only uses it when the target is Windows.
@@ -35,54 +31,17 @@ winresource = "0.1"
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
# traffic lights, the window, button and view ones.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
objc2 = "0.6"
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
# the build through arboard, at this version; this adds only the NSString bindings.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
# inside the AppKit run loop (see `schedule_macos_terminate`).
dispatch2 = "0.3"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
# The desktop's animation setting on Linux and the BSDs, read from the XDG settings portal over
# D-Bus. Already in the build through AccessKit, at this version; this adds only its blocking API.
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
# Wayland's clipboard through the data-control protocol (#687, #833): the RTF and other picture
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
# `wayland-data-control` feature, at this version.
wl-clipboard-rs = "0.9"
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
open = "5"
# The system's Animation effects setting (Settings › Accessibility › Visual effects), read through
# the Windows Runtime's UISettings: a safe API, where the Win32 call needs unsafe, which our
# workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds
# only the `UI_ViewManagement` bindings, and `System_UserProfile` for the home region new
# documents take their paper and units from (#838).
windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfile"] }
# The clipboard's "Rich Text Format" and the names of its formats (#687, #833), which arboard
# doesn't read. Already in the build through arboard, at this version.
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
pollster = "1"
pollster = "0.4"
# Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already
# in the build through egui-winit, with the same features.
arboard = { workspace = true, features = ["image-data"] }
# The control server's random key.
getrandom = "0.3"
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
# File › Print opens the PDF in the system's default viewer (`xdg-open`/`gio` on Linux and BSD, the
# file association on Windows, `open` on macOS) without blocking; already in the tree via egui_kittest.
open = "5.4"
-99
View File
@@ -1,99 +0,0 @@
//! The system's animation setting, which File › Options › Accessibility › Animation effects
//! follows when set to "Use system setting" (`Services::animations_enabled`).
//!
//! - Windows: Settings › Accessibility › Visual effects › Animation effects, read through the
//! Windows Runtime's `UISettings`.
//! - macOS: System Settings › Accessibility › Display › Reduce motion, read through `NSWorkspace`.
//! - Linux and the BSDs: the desktop's setting, read through the settings portal ([`desktop`]).
//!
//! All three are safe APIs of crates already in the build (our workspace forbids unsafe). The UI
//! asks every frame, so the answer always comes from memory: Windows and macOS are asked again at
//! most once a second, and the Linux/BSD side keeps its answer up to date on a thread of its own.
#[cfg(any(all(unix, not(target_os = "macos")), test))]
mod desktop;
/// The answer to "does the system have animation effects on?" for the UI: `None` when the system
/// doesn't say. `ctx` is asked to redraw when an answer that arrives later changes it.
pub fn hook(ctx: &egui::Context) -> Box<dyn Fn() -> Option<bool>> {
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
let _ = ctx;
Box::new(remembered(system_enabled, REFRESH))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Box::new(desktop::watch(ctx.clone()))
}
#[cfg(not(any(target_os = "windows", unix)))]
{
let _ = ctx;
Box::new(|| None)
}
}
/// How long an answer from Windows or macOS is kept before the system is asked again.
#[cfg(any(target_os = "windows", target_os = "macos"))]
const REFRESH: std::time::Duration = std::time::Duration::from_secs(1);
/// `read`, asked at most once per `every` and answered from memory in between.
#[cfg(any(target_os = "windows", target_os = "macos", test))]
fn remembered(read: impl Fn() -> Option<bool>, every: std::time::Duration) -> impl Fn() -> Option<bool> {
let last = std::cell::Cell::new(None::<(std::time::Instant, Option<bool>)>);
move || {
if let Some((at, answer)) = last.get()
&& at.elapsed() < every
{
return answer;
}
let answer = read();
last.set(Some((std::time::Instant::now(), answer)));
answer
}
}
/// Whether Windows has animation effects on; `None` when it can't be asked.
#[cfg(target_os = "windows")]
fn system_enabled() -> Option<bool> {
windows::UI::ViewManagement::UISettings::new()
.and_then(|settings| settings.AnimationsEnabled())
.map_err(|e| log::debug!("system animation setting: {e}"))
.ok()
}
/// Whether macOS plays animations: Reduce motion is off.
#[cfg(target_os = "macos")]
fn system_enabled() -> Option<bool> {
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::time::Duration;
use super::remembered;
/// The UI asks every frame; the system is asked once per interval and the answer kept between.
#[test]
fn the_system_is_asked_once_per_interval() {
let asked = Cell::new(0);
let system = Cell::new(Some(false));
let read = || {
asked.set(asked.get() + 1);
system.get()
};
let kept = remembered(read, Duration::from_secs(3600));
assert_eq!((kept(), kept(), kept()), (Some(false), Some(false), Some(false)));
system.set(Some(true));
assert_eq!(kept(), Some(false), "within the interval the remembered answer stands");
assert_eq!(asked.get(), 1);
// Once the interval has passed (here: at once) the system is asked again.
let fresh = remembered(read, Duration::ZERO);
assert_eq!(fresh(), Some(true));
system.set(None);
assert_eq!(fresh(), None);
assert_eq!(asked.get(), 3);
}
}
-305
View File
@@ -1,305 +0,0 @@
//! The desktop's animation setting on Linux and the BSDs.
//!
//! Asked of the XDG settings portal over D-Bus (`org.freedesktop.portal.Settings`), which GNOME,
//! KDE Plasma and the other desktops with a portal answer, inside Flatpak too. Three settings say
//! whether animations play ([`KEYS`]); any one of them saying no turns them off. Where no portal
//! answers, or it knows none of the three (a bare window manager), the user's own KDE and GTK
//! settings files are read instead, once.
//!
//! A thread asks at start-up and then follows the portal's change signal, so the UI's question is
//! always answered from memory and a change in the desktop's settings shows without a restart.
/// What a setting holds, whichever way it was stored.
#[derive(Clone, Debug, PartialEq)]
pub enum Reading {
Bool(bool),
Number(f64),
Text(String),
}
/// A setting as the portal names it: (namespace, key).
type Key = (&'static str, &'static str);
/// The cross-desktop preference: 1 asks for reduced motion; 0, and values not defined yet, are
/// no preference.
const REDUCED_MOTION: Key = ("org.freedesktop.appearance", "reduced-motion");
/// GNOME's switch, the one GTK follows.
const GNOME_ANIMATIONS: Key = ("org.gnome.desktop.interface", "enable-animations");
/// KDE Plasma's animation speed, a factor on every animation's length: 0 makes them instant.
const KDE_ANIMATION_SPEED: Key = ("org.kde.kdeglobals.KDE", "AnimationDurationFactor");
/// The settings that say whether animations play.
pub const KEYS: [Key; 3] = [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED];
/// Whether animations play according to one of [`KEYS`]; `None` for any other setting, or a
/// value that isn't one it holds.
pub fn says(namespace: &str, key: &str, value: &Reading) -> Option<bool> {
match (namespace, key) {
k if k == REDUCED_MOTION => number(value).map(|n| n != 1.0),
k if k == GNOME_ANIMATIONS => truth(value),
k if k == KDE_ANIMATION_SPEED => number(value).map(|n| n > 0.0),
_ => None,
}
}
fn number(value: &Reading) -> Option<f64> {
let n = match value {
Reading::Number(n) => *n,
Reading::Text(t) => t.trim().parse().ok()?,
Reading::Bool(_) => return None,
};
n.is_finite().then_some(n)
}
fn truth(value: &Reading) -> Option<bool> {
match value {
Reading::Bool(b) => Some(*b),
Reading::Number(n) => n.is_finite().then_some(*n != 0.0),
Reading::Text(t) => match t.trim().to_ascii_lowercase().as_str() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
},
}
}
/// Several settings' answers as one: off as soon as one says off, on when at least one says on,
/// `None` when none says anything.
pub fn combine(answers: impl IntoIterator<Item = Option<bool>>) -> Option<bool> {
let mut any = None;
for on in answers.into_iter().flatten() {
if !on {
return Some(false);
}
any = Some(true);
}
any
}
/// The value of `key` in `[section]` of a settings file in INI form (KDE's `kdeglobals`, GTK's
/// `settings.ini`). The last one wins, as the toolkits read it.
pub fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> {
let mut inside = false;
let mut found = None;
for line in text.lines().map(str::trim) {
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
inside = name.trim() == section;
} else if inside
&& !line.starts_with(['#', ';'])
&& let Some((k, v)) = line.split_once('=')
&& k.trim() == key
{
found = Some(v.trim());
}
}
found
}
/// What the user's own settings files say, given their text (`None` for a file that isn't
/// there): KDE Plasma's `kdeglobals` and GTK's `settings.ini`s.
pub fn files_say(kdeglobals: Option<&str>, gtk_settings: [Option<&str>; 2]) -> Option<bool> {
let (namespace, key) = KDE_ANIMATION_SPEED;
let kde = kdeglobals.and_then(|text| ini_value(text, "KDE", key)).and_then(|v| says(namespace, key, &Reading::Text(v.to_string())));
let gtk = gtk_settings
.into_iter()
.map(|text| text.and_then(|t| ini_value(t, "Settings", "gtk-enable-animations")).and_then(|v| truth(&Reading::Text(v.to_string()))));
combine(std::iter::once(kde).chain(gtk))
}
#[cfg(all(unix, not(target_os = "macos")))]
pub use watching::watch;
#[cfg(all(unix, not(target_os = "macos")))]
mod watching {
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::{OwnedValue, Value};
use super::{KEYS, Reading, combine, files_say, says};
// The latest answer, as one byte the UI thread reads without waiting.
const UNKNOWN: u8 = 0;
const ON: u8 = 1;
const OFF: u8 = 2;
/// Start following the desktop's animation setting. The returned function gives the latest
/// answer from memory (`None` until the first one arrives, and when the desktop says nothing);
/// `ctx` is asked to redraw when the answer changes.
pub fn watch(ctx: egui::Context) -> impl Fn() -> Option<bool> {
let latest = Arc::new(AtomicU8::new(UNKNOWN));
let shared = latest.clone();
let spawned = std::thread::Builder::new().name("animation-setting".into()).spawn(move || {
let publish = |answer: Option<bool>| {
let code = match answer {
Some(true) => ON,
Some(false) => OFF,
None => UNKNOWN,
};
if shared.swap(code, Ordering::Relaxed) != code {
ctx.request_repaint();
}
};
if let Err(e) = follow_portal(&publish) {
log::debug!("desktop animation setting: no settings portal ({e}); reading the settings files");
publish(read_files());
}
});
if let Err(e) = spawned {
log::warn!("desktop animation setting: {e}");
}
move || match latest.load(Ordering::Relaxed) {
ON => Some(true),
OFF => Some(false),
_ => None,
}
}
/// Publish what the settings portal says, now and after every change it announces, until the
/// connection ends; an error when there is no session bus to ask on. While the portal knows
/// none of the settings, the files' answer stands.
fn follow_portal(publish: &dyn Fn(Option<bool>)) -> zbus::Result<()> {
let connection = Connection::session()?;
let portal = Proxy::new(&connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
// Listen before the first read, so a change between the two isn't missed.
let changes = portal.receive_signal("SettingChanged")?;
// The files are read once, the first time the portal has nothing to say.
let mut files = None;
let mut ask = || ask_portal(&portal).or_else(|| *files.get_or_insert_with(read_files));
publish(ask());
// A setting can turn up later (KDE's has no value while the speed is at its default), so
// the changes are followed whatever the portal knew at first. Every setting is asked
// again, not just the one announced: KDE announces a new animation speed only as the
// cross-desktop preference, and its own setting must not be left at the old value.
for change in changes {
let ours = change
.body()
.deserialize::<(String, String, OwnedValue)>()
.is_ok_and(|(namespace, key, _)| KEYS.contains(&(namespace.as_str(), key.as_str())));
if ours {
publish(ask());
}
}
Ok(())
}
/// What the portal's settings come to; `None` when it has none of them.
fn ask_portal(portal: &Proxy<'_>) -> Option<bool> {
combine(KEYS.map(|(namespace, key)| read_setting(portal, namespace, key).and_then(|value| says(namespace, key, &value))))
}
/// One setting from the portal; `None` when it doesn't have it.
fn read_setting(portal: &Proxy<'_>, namespace: &str, key: &str) -> Option<Reading> {
// `ReadOne` is the current call; portals from before it only have `Read`.
let value: OwnedValue = portal.call("ReadOne", &(namespace, key)).or_else(|_| portal.call("Read", &(namespace, key))).ok()?;
reading(&value, 0)
}
fn reading(value: &Value<'_>, depth: u8) -> Option<Reading> {
Some(match value {
Value::Bool(b) => Reading::Bool(*b),
Value::U8(n) => Reading::Number(f64::from(*n)),
Value::I16(n) => Reading::Number(f64::from(*n)),
Value::U16(n) => Reading::Number(f64::from(*n)),
Value::I32(n) => Reading::Number(f64::from(*n)),
Value::U32(n) => Reading::Number(f64::from(*n)),
Value::I64(n) => Reading::Number(*n as f64),
Value::U64(n) => Reading::Number(*n as f64),
Value::F64(n) => Reading::Number(*n),
Value::Str(s) => Reading::Text(s.as_str().to_string()),
// `Read` wraps its answer in a second variant.
Value::Value(inner) if depth < 4 => return reading(inner, depth + 1),
_ => return None,
})
}
/// What the user's settings files say (see [`files_say`]).
fn read_files() -> Option<bool> {
let config = std::env::var_os("XDG_CONFIG_HOME")
.filter(|dir| !dir.is_empty())
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
let text = |file: &str| read_capped(&config.join(file));
let (kdeglobals, gtk4, gtk3) = (text("kdeglobals"), text("gtk-4.0/settings.ini"), text("gtk-3.0/settings.ini"));
files_say(kdeglobals.as_deref(), [gtk4.as_deref(), gtk3.as_deref()])
}
/// A settings file's text: at most 256 KiB of it (they are a few kilobytes).
fn read_capped(path: &Path) -> Option<String> {
use std::io::Read as _;
let mut text = String::new();
std::fs::File::open(path).ok()?.take(256 * 1024).read_to_string(&mut text).ok()?;
Some(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(s: &str) -> Reading {
Reading::Text(s.to_string())
}
/// Each desktop's setting, as the portal hands it over, comes to animations on or off.
#[test]
fn each_desktop_setting_says_on_or_off() {
let ((appearance, reduced), (gnome, enable), (kde, factor)) = (REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED);
assert_eq!(KEYS, [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED]);
// The cross-desktop preference: only 1 asks for reduced motion.
assert_eq!(says(appearance, reduced, &Reading::Number(1.0)), Some(false));
assert_eq!(says(appearance, reduced, &Reading::Number(0.0)), Some(true));
assert_eq!(says(appearance, reduced, &Reading::Number(7.0)), Some(true), "a value not defined yet is no preference");
assert_eq!(says(appearance, reduced, &Reading::Bool(true)), None);
// GNOME's switch.
assert_eq!(says(gnome, enable, &Reading::Bool(false)), Some(false));
assert_eq!(says(gnome, enable, &Reading::Bool(true)), Some(true));
assert_eq!(says(gnome, enable, &text("nonsense")), None);
// KDE Plasma's animation speed arrives as text: 0 is instant.
assert_eq!(says(kde, factor, &text("0")), Some(false));
assert_eq!(says(kde, factor, &text(" 0.5 ")), Some(true));
assert_eq!(says(kde, factor, &Reading::Number(1.0)), Some(true));
for junk in [text(""), text("fast"), Reading::Number(f64::NAN), text("inf")] {
assert_eq!(says(kde, factor, &junk), None, "{junk:?}");
}
// Any other setting says nothing.
assert_eq!(says(gnome, "color-scheme", &Reading::Bool(false)), None);
assert_eq!(says("org.example", enable, &Reading::Bool(false)), None);
}
/// One setting saying off is enough; none saying anything is no answer.
#[test]
fn any_setting_saying_off_turns_animations_off() {
assert_eq!(combine([]), None);
assert_eq!(combine([None, None]), None);
assert_eq!(combine([None, Some(true)]), Some(true));
assert_eq!(combine([Some(true), None, Some(false)]), Some(false));
assert_eq!(combine([Some(false), Some(true)]), Some(false));
}
/// Without a portal the user's own settings files answer: KDE's animation speed and GTK's
/// `gtk-enable-animations`.
#[test]
fn settings_files_answer_without_a_portal() {
let kde_instant = "[General]\nColorScheme=Breeze\n\n[KDE]\nAnimationDurationFactor=0\nSingleClick=false\n";
let kde_slow = "[KDE]\nAnimationDurationFactor = 2\n";
let gtk_off = "[Settings]\ngtk-theme-name=Adwaita\ngtk-enable-animations=0\n";
let gtk_on = "# written by hand\n[Settings]\ngtk-enable-animations=true\n";
assert_eq!(files_say(None, [None, None]), None);
assert_eq!(files_say(Some("[KDE]\nSingleClick=false\n"), [Some("[Settings]\n"), None]), None, "files that don't mention it");
assert_eq!(files_say(Some(kde_instant), [None, None]), Some(false));
assert_eq!(files_say(Some(kde_slow), [None, None]), Some(true));
assert_eq!(files_say(None, [Some(gtk_off), None]), Some(false));
assert_eq!(files_say(None, [None, Some(gtk_on)]), Some(true));
assert_eq!(files_say(Some(kde_slow), [Some(gtk_on), Some(gtk_off)]), Some(false), "one file saying off is enough");
// The key counts only in its own section, comments don't, and the last value wins.
assert_eq!(ini_value("[Other]\nAnimationDurationFactor=0\n[KDE]\nSingleClick=true\n", "KDE", "AnimationDurationFactor"), None);
assert_eq!(ini_value("[KDE]\n#AnimationDurationFactor=0\n;AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None);
assert_eq!(ini_value("[KDE]\r\nAnimationDurationFactor=0\r\nAnimationDurationFactor=1\r\n", "KDE", "AnimationDurationFactor"), Some("1"));
assert_eq!(ini_value("AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None, "outside any section");
assert_eq!(ini_value("", "KDE", "AnimationDurationFactor"), None);
}
}
+4 -10
View File
@@ -35,15 +35,14 @@ impl AppleEvents {
}
/// Open the documents and act on the quit requests that arrived since the last frame. A document
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
/// is already open, by showing it. Quitting closes the window, which asks about every document
/// with unsaved changes.
/// opens like File › Open (asking about unsaved changes first); quitting closes the window, which
/// asks the same.
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
match app.open_file(p, None) {
match app.run("file.open", serde_json::json!({"path": p})) {
// Leave Backstage, as opening from the Open dialog does.
Ok(_) => app.ui.backstage = false,
Err(e) => {
@@ -55,12 +54,7 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
}
}
}
// Quitting asks about every document with unsaved changes, each in its window; the
// app closes once that's done (`quit_requested`).
Event::Quit => {
app.quit();
ctx.request_repaint();
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
-333
View File
@@ -1,333 +0,0 @@
//! The system clipboard beyond egui's text (#45, #584, #687, #833): text for the ribbon's Paste,
//! another program's formatted copy (HTML, and RTF where the platform offers it), pictures and
//! copied files, and the names of the formats there for a paste that finds nothing it can use.
//!
//! On Linux, arboard talks to Wayland compositors through the data-control protocol (KDE Plasma,
//! wlroots compositors); without it, a native Wayland window only sees what the compositor copies
//! over to Xwayland, which KWin does only while an X11 window has focus — so pictures copied in a
//! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11.
//!
//! Everything read here comes from other programs: sizes are capped as it is read.
//!
//! Written here: a copy puts HTML beside its text, so other programs keep its formatting (#1523),
//! with equations as MathML (#816). arboard sets HTML with a text alternative on every desktop
//! platform. A copy of one equation alone also goes in the platform's MathML formats (#1297),
//! added beside arboard's: on Windows "MathML" and "MathML Presentation" (UTF-16LE), on macOS
//! `public.mathml.presentation` and `public.mathml` — the names W3C MathML 3 §6.2.4 registers
//! for transferring MathML, and the ones Microsoft documents its Office apps reading first when
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
//! add MIME types of other names, so there the HTML carries the MathML alone.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
fn open() -> Option<arboard::Clipboard> {
arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()
}
thread_local! {
/// The clipboard copies are set through, kept open: on X11 the contents are served from it.
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
}
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
/// session without the data-control protocol, where arboard would fall back to X11 and native
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
/// carries it.
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
if !platform::can_set() {
return false;
}
let set = set_html_text(html, text);
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
platform::add_mathml(mathml);
}
set
}
fn set_html_text(html: &str, text: &str) -> bool {
WRITER.with(|w| {
let Ok(mut slot) = w.try_borrow_mut() else { return false };
if slot.is_none() {
*slot = open();
}
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
Some(Ok(())) => true,
Some(Err(e)) => {
log::warn!("clipboard: {e}");
*slot = None;
false
}
None => false,
}
})
}
/// The clipboard's text.
pub fn text() -> Option<String> {
open()?.get_text().ok()
}
/// The clipboard's HTML (Windows: the fragment of its "HTML Format"), if not too large.
pub fn html() -> Option<String> {
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
}
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
/// arboard, so read here.
pub fn rtf() -> Option<String> {
let bytes = platform::rtf()?;
if bytes.len() > MAX_PASTE_BYTES {
return None;
}
// RTF is 7-bit by the spec; stray 8-bit bytes become replacement characters.
let s = String::from_utf8_lossy(&bytes).trim_end_matches('\0').to_string();
s.trim_start().starts_with("{\\rtf").then_some(s)
}
/// The names of the formats on the clipboard, where the platform lists them (Wayland, Windows).
pub fn formats() -> Vec<String> {
platform::formats()
}
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
/// from the header before anything is decoded (#3623).
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
Err(e) => {
log::warn!("clipboard: {e}");
return Some(ClipboardPicture::Unreadable(format!("the system clipboard couldn't be opened ({e})")));
}
};
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
decoded_picture(&mut clipboard)
}
/// The clipboard's picture on Windows, read past arboard, which decodes the PNG and DIB formats
/// with no size limits (#3623).
#[cfg(target_os = "windows")]
fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
platform::picture()
}
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
#[cfg(not(target_os = "windows"))]
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
Err(arboard::Error::ConversionFailure) => Some(
platform::encoded_picture()
.map(ClipboardPicture::Encoded)
.unwrap_or_else(|| ClipboardPicture::Unreadable("WordCraft can't decode its format".into())),
),
Err(e) => {
log::warn!("clipboard picture: {e}");
platform::encoded_picture().map(ClipboardPicture::Encoded).or(Some(ClipboardPicture::Unreadable(e.to_string())))
}
}
}
/// Picture types read straight from Wayland when arboard (PNG only) finds none, best first.
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
const PICTURE_MIMES: [&str; 6] = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/x-bmp"];
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
mod platform {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
}
/// One MIME type's bytes, at most `cap`; `None` when absent or larger.
fn read(mime: &str, cap: u64) -> Option<Vec<u8>> {
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
let mut out = Vec::new();
pipe.take(cap.saturating_add(1)).read_to_end(&mut out).ok()?;
(out.len() as u64 <= cap).then_some(out)
}
pub fn formats() -> Vec<String> {
if !wayland() {
return Vec::new();
}
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
}
/// Whether arboard sets the clipboard natively: X11, or Wayland with data-control.
pub fn can_set() -> bool {
!wayland()
|| !matches!(
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified),
Err(wl_clipboard_rs::paste::Error::MissingProtocol { .. } | wl_clipboard_rs::paste::Error::WaylandConnection(_))
)
}
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
pub fn add_mathml(_mathml: &str) {}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
}
let offered = formats();
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, super::MAX_PASTE_BYTES as u64)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
}
}
#[cfg(target_os = "windows")]
mod platform {
use clipboard_win::{Clipboard, EnumFormats, raw};
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
pub fn formats() -> Vec<String> {
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
// Standard formats are named too (CF_BITMAP, CF_UNICODETEXT…).
EnumFormats::new().take(64).filter_map(raw::format_name_big).collect()
}
pub fn rtf() -> Option<Vec<u8>> {
let format = raw::register_format("Rich Text Format")?;
let _open = Clipboard::new_attempts(10).ok()?;
if raw::size(format.get()).is_none_or(|n| n.get() > super::MAX_PASTE_BYTES) {
return None;
}
let mut out = Vec::new();
raw::get_vec(format.get(), &mut out).ok()?;
Some(out)
}
/// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as
/// pixels, each refused from its header when a paste can't take its size (#3623). None when
/// it holds neither.
pub fn picture() -> Option<ClipboardPicture> {
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
let Ok(_open) = Clipboard::new_attempts(10) else {
return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into()));
};
if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) {
return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture));
}
let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?;
Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture))
}
/// A clipboard format's bytes, refused past the largest picture a paste takes.
fn read(format: u32) -> Result<Vec<u8>, String> {
match raw::size(format) {
Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {}
Some(_) => return Err("the clipboard picture is larger than 200 MB".into()),
None => return Err("the clipboard picture is empty".into()),
}
let mut out = Vec::new();
raw::get_vec(format, &mut out).map_err(|e| e.to_string())?;
Ok(out)
}
pub fn can_set() -> bool {
true
}
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
/// text (with its terminating NUL, as Windows text formats have).
pub fn add_mathml(mathml: &str) {
let Ok(_open) = Clipboard::new_attempts(10) else {
log::warn!("clipboard: couldn't open it to add MathML");
return;
};
let bytes: Vec<u8> = mathml.encode_utf16().chain(std::iter::once(0)).flat_map(u16::to_le_bytes).collect();
for name in super::MATHML_FORMATS {
let Some(format) = raw::register_format(name) else { continue };
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
log::warn!("clipboard: {name}: {e}");
}
}
}
}
/// The clipboard format names for MathML that Windows programs read (W3C MathML 3 §6.2.4; Office
/// reads and writes both).
#[cfg(target_os = "windows")]
const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
#[cfg(target_os = "macos")]
mod platform {
use objc2_app_kit::NSPasteboard;
use objc2_foundation::NSString;
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
true
}
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
pub fn add_mathml(mathml: &str) {
let board = NSPasteboard::generalPasteboard();
let text = NSString::from_str(mathml);
for name in ["public.mathml.presentation", "public.mathml"] {
if !board.setString_forType(&text, &NSString::from_str(name)) {
log::warn!("clipboard: couldn't add {name}");
}
}
}
}
#[cfg(not(any(
target_os = "windows",
target_os = "macos",
all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten")))
)))]
mod platform {
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
true
}
pub fn add_mathml(_mathml: &str) {}
}
+1 -10
View File
@@ -484,16 +484,7 @@ mod tests {
assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none(), "no key file after a failed bind");
drop(taken);
// Bound: the key file holds the key that opens the port.
let mut free_port = port.wrapping_add(1).max(1024);
let mut started = None;
for _ in 0..100 {
if let Some(res) = start(free_port, egui::Context::default(), Some(&dir)) {
started = Some((res, free_port));
break;
}
free_port = free_port.wrapping_add(1).max(1024);
}
let ((rx, key_file), port) = started.unwrap();
let (rx, key_file) = start(port, egui::Context::default(), Some(&dir)).unwrap();
let key_file = key_file.unwrap();
assert_eq!(key_file.path(), wordcraft_control_key::key_path(&dir, port));
let key = std::fs::read_to_string(key_file.path()).unwrap();
-194
View File
@@ -1,194 +0,0 @@
//! File › Options › General › Make WordCraft the default for Word documents (#295).
//!
//! The installers register WordCraft for Word, RTF and ODT files without taking over the
//! defaults (`packaging/windows/wordcraft.wxs`, the `.desktop` file's `MimeType=`, `Info.plist`).
//! This is the button that makes it the default when the user asks:
//! - **Windows** doesn't let apps set defaults: it opens Settings › Default apps on WordCraft's
//! page (`ms-settings:defaultapps?registeredAppMachine=WordCraft`; Windows 10 ignores the query
//! and shows the Default apps list), where the user picks WordCraft per type.
//! - **Linux and BSD**: `xdg-mime default ai.storyteller.wordcraft.desktop <types>` for
//! [`WORD_MIME_TYPES`], which writes the user's `mimeapps.list`. Only when the desktop entry is
//! installed (deb, rpm, Flatpak; a bare AppImage has none), and not from inside the Flatpak
//! sandbox, which can't change the host's defaults.
//! - **macOS** has no API for it here: the Options page explains Finder's Get Info › Change All.
//!
//! [`WORD_MIME_TYPES`]: self::WORD_MIME_TYPES
use wordcraft_ui_egui::DefaultApp;
/// The `Services::make_default_app` hook.
pub type Hook = Box<dyn Fn() -> Result<DefaultApp, String>>;
/// The hook for this platform; `None` where there is no button (macOS).
pub fn hook() -> Option<Hook> {
#[cfg(target_os = "windows")]
let hook: Option<Hook> = Some(Box::new(windows::open_default_apps));
#[cfg(all(unix, not(target_os = "macos")))]
let hook: Option<Hook> = Some(Box::new(xdg::make_default));
#[cfg(not(any(target_os = "windows", all(unix, not(target_os = "macos")))))]
let hook: Option<Hook> = None;
hook
}
/// The desktop entry the Linux packages install, and the Flatpak's app id.
#[cfg(any(all(unix, not(target_os = "macos")), test))]
const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
#[cfg(any(all(unix, not(target_os = "macos")), test))]
const WORD_MIME_TYPES: &[&str] = &[
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.ms-word.document.macroEnabled.12", // .docm
"application/vnd.openxmlformats-officedocument.wordprocessingml.template", // .dotx
"application/vnd.ms-word.template.macroEnabled.12", // .dotm
"application/msword", // .doc
"application/msword-template", // .dot
"application/rtf", // .rtf
"text/rtf", // .rtf (older name)
"application/vnd.oasis.opendocument.text", // .odt
];
/// The arguments for `xdg-mime` that make `desktop_id` the default for [`WORD_MIME_TYPES`]: one
/// call sets them all.
#[cfg(any(all(unix, not(target_os = "macos")), test))]
fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
["default", desktop_id].into_iter().chain(WORD_MIME_TYPES.iter().copied()).map(str::to_string).collect()
}
/// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default
/// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths
/// are ignored, as the XDG Base Directory spec says.
#[cfg(all(unix, any(not(target_os = "macos"), test)))]
fn application_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&str>) -> Vec<std::path::PathBuf> {
use std::path::{Path, PathBuf};
let absolute = |s: &&str| Path::new(s).is_absolute();
let home_dir = data_home
.filter(|s| !s.is_empty())
.filter(absolute)
.map(PathBuf::from)
.or_else(|| home.filter(|s| !s.is_empty()).filter(absolute).map(|h| Path::new(h).join(".local/share")));
let system = data_dirs.filter(|s| !s.is_empty()).unwrap_or("/usr/local/share:/usr/share");
home_dir
.into_iter()
.chain(system.split(':').filter(|s| !s.is_empty()).filter(absolute).map(PathBuf::from))
.map(|d| d.join("applications"))
.collect()
}
#[cfg(target_os = "windows")]
mod windows {
use wordcraft_ui_egui::DefaultApp;
/// Settings › Default apps, on the page of the app registered as `WordCraft` under
/// `HKLM\Software\RegisteredApplications` (the MSI writes it).
const SETTINGS_URI: &str = "ms-settings:defaultapps?registeredAppMachine=WordCraft";
pub(super) fn open_default_apps() -> Result<DefaultApp, String> {
open::that_detached(SETTINGS_URI).map_err(|e| format!("couldn't open Settings: {e}"))?;
Ok(DefaultApp::SettingsOpened)
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod xdg {
use std::process::Command;
use wordcraft_ui_egui::DefaultApp;
use super::{DESKTOP_ID, application_dirs, xdg_mime_args};
/// The longest error text from `xdg-mime` shown in the status bar.
const MAX_ERROR: usize = 200;
pub(super) fn make_default() -> Result<DefaultApp, String> {
if std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists() {
return Err(
"the Flatpak sandbox can't change default apps; choose WordCraft under Open With in your file manager or system settings".into()
);
}
let var = |k: &str| std::env::var(k).ok();
let dirs = application_dirs(var("XDG_DATA_HOME").as_deref(), var("XDG_DATA_DIRS").as_deref(), var("HOME").as_deref());
if !dirs.iter().any(|d| d.join(DESKTOP_ID).is_file()) {
return Err(format!("{DESKTOP_ID} isn't installed (an AppImage needs desktop integration first)"));
}
let out = Command::new("xdg-mime").args(xdg_mime_args(DESKTOP_ID)).output().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => "xdg-mime isn't installed (it comes with xdg-utils)".to_string(),
_ => format!("couldn't run xdg-mime: {e}"),
})?;
if out.status.success() {
return Ok(DefaultApp::MadeDefault);
}
let stderr = String::from_utf8_lossy(&out.stderr);
let first = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
let message: String = first.chars().take(MAX_ERROR).collect();
Err(if message.is_empty() { format!("xdg-mime failed ({})", out.status) } else { format!("xdg-mime failed: {message}") })
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::path::PathBuf;
use super::*;
#[test]
fn xdg_mime_sets_every_word_type_in_one_call() {
let args = xdg_mime_args(DESKTOP_ID);
assert_eq!(args.first().map(String::as_str), Some("default"));
assert_eq!(args.get(1).map(String::as_str), Some("ai.storyteller.wordcraft.desktop"));
assert_eq!(&args[2..], WORD_MIME_TYPES);
for t in [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"application/rtf",
"application/vnd.oasis.opendocument.text",
] {
assert!(args.iter().any(|a| a == t), "{t}");
}
assert!(!args.iter().any(|a| a == "text/plain" || a == "text/markdown"), "plain text and Markdown are never claimed");
}
/// Every type the button claims is one the desktop entry declares, or desktops ignore it.
#[test]
fn the_desktop_entry_declares_every_claimed_type() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
let declared: Vec<&str> =
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
for t in WORD_MIME_TYPES {
assert!(declared.contains(t), "{t} missing from MimeType=");
}
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
}
/// Declaring `text/plain` makes WordCraft a handler for every config, script and log on systems with no default set.
#[test]
fn the_desktop_entry_does_not_declare_plain_text() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
let declared: Vec<&str> =
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
assert!(!declared.is_empty(), "MimeType= line not found");
assert!(!declared.contains(&"text/plain"), "text/plain must not be declared");
}
#[cfg(unix)]
#[test]
fn application_dirs_follow_the_xdg_spec() {
let p = |s: &str| PathBuf::from(s);
assert_eq!(
application_dirs(None, None, Some("/home/a")),
[p("/home/a/.local/share/applications"), p("/usr/local/share/applications"), p("/usr/share/applications")]
);
assert_eq!(
application_dirs(Some("/data"), Some("/opt/share::relative:/usr/share"), Some("/home/a")),
[p("/data/applications"), p("/opt/share/applications"), p("/usr/share/applications")]
);
// Empty or relative values fall back to the defaults; no home at all is fine.
assert_eq!(application_dirs(Some(""), Some(""), None), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
assert_eq!(application_dirs(Some("rel"), None, Some("also-rel")), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
}
}
+21 -249
View File
@@ -6,34 +6,22 @@
//! is queued; the frame (which has the window) builds an `rfd::AsyncFileDialog` parented to the
//! window, and a background thread waits for the answer, hands it to the app and wakes the UI.
//!
//! On Linux and the BSDs rfd asks the xdg-desktop-portal FileChooser and, failing that, runs
//! `zenity`; with neither (a minimal Wayland compositor such as labwc with only
//! xdg-desktop-portal-wlr, #576) it answers "cancelled" and nothing ever shows. So the waiting
//! thread first checks that one of them is there and otherwise answers with an error, and the app
//! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one.
//!
//! On Linux and the BSDs, rfd reports a portal that failed to show its dialog as a cancel (#2999).
//! There a cancel quicker than anyone could dismiss a dialog is taken as that failure: the dialog
//! is asked for once more without a parent window, and if that also comes back at once the
//! built-in browser shows. Windows and macOS always show their dialog, so a quick cancel there is
//! the person's and is answered as one.
//!
//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog
use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::{Receiver, Sender, channel};
use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest};
use wordcraft_ui_egui::FileDialogRequest;
/// A dialog the app asked for, with the channel its answer goes back through.
type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
type Request = (FileDialogRequest, Sender<Option<String>>);
/// The `Services::file_dialog` hook.
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<Option<String>>>;
/// A picker's answer, waited for off the UI thread.
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
/// Shows the dialogs the app queued; lives in the eframe app, which has the window.
pub struct Launcher {
@@ -60,23 +48,14 @@ impl Launcher {
pub fn show(&self, parent: &eframe::Frame) {
while let Ok((req, answer)) = self.queue.try_recv() {
// Built here, on the UI thread: macOS shows the panel as a sheet on the window now.
let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);
#[cfg(all(unix, not(target_os = "macos")))]
let unparented = picker(rfd::AsyncFileDialog::new(), &req);
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
let picking: Picking = match req {
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
FileDialogRequest::Save { name } => Box::pin(dialog.set_file_name(name).save_file()),
};
let ctx = self.ctx.clone();
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
// The pickers only start when first polled: without a system dialog they never do.
let picked = match no_system_dialog() {
Some(reason) => Err(reason),
None => {
// Only the portal folds a failure into "cancelled" (#2999).
#[cfg(all(unix, not(target_os = "macos")))]
let picked = pick(picking, unparented, TOO_QUICK);
#[cfg(not(all(unix, not(target_os = "macos"))))]
let picked: Result<_, String> = Ok(pollster::block_on(picking));
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
}
};
let picked = pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string());
let _ = answer.send(picked);
ctx.request_repaint();
});
@@ -88,223 +67,16 @@ impl Launcher {
}
}
/// The picker for `req` on `dialog`.
fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking {
match req.clone() {
FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }),
FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }),
FileDialogRequest::Save { name, dir } => {
let mut dialog = dialog.set_file_name(name.clone());
// Save as Word Template starts in the user templates folder (#2852).
if let Some(dir) = dir.filter(|d| std::path::Path::new(d).is_dir()) {
dialog = dialog.set_directory(dir);
}
for (label, exts) in save_filters(&name) {
dialog = dialog.add_filter(label, &exts);
}
Box::pin(async move { dialog.save_file().await.into_iter().collect() })
}
}
}
/// Quicker than anyone can see a dialog and dismiss it.
// ponytail: a timing heuristic, as rfd folds the portal's failure code into "cancelled". A portal
// failing slower than this still reads as a cancel; ask rfd for the response code if it matters.
#[cfg(all(unix, not(target_os = "macos")))]
const TOO_QUICK: std::time::Duration = std::time::Duration::from_millis(300);
/// Wait for `picking`. Nothing picked within `too_quick` is a portal that failed to show its
/// dialog (#2999), not a person cancelling: try `unparented`, as a portal may fail on the parent
/// window; if that too comes back at once, an error, so the app shows its built-in browser.
/// Linux and the BSDs only (built everywhere for its test).
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
fn pick<T>(
picking: impl Future<Output = Vec<T>>,
unparented: impl Future<Output = Vec<T>>,
too_quick: std::time::Duration,
) -> Result<Vec<T>, String> {
fn timed<T>(f: impl Future<Output = Vec<T>>, too_quick: std::time::Duration) -> (Vec<T>, bool) {
let start = std::time::Instant::now();
let picked = pollster::block_on(f);
let quick = picked.is_empty() && start.elapsed() < too_quick;
(picked, quick)
}
match timed(picking, too_quick) {
(_, true) => log::warn!("the system file dialog closed at once; asking again without a parent window"),
(picked, false) => return Ok(picked),
}
match timed(unparented, too_quick) {
(_, true) => Err("the system file dialog closed at once".into()),
(picked, false) => Ok(picked),
}
}
/// The file types Open offers (shared with the built-in browser).
fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
for (name, exts) in wordcraft_ui_egui::open_filters(purpose) {
d = d.add_filter(*name, exts);
}
d
}
/// Why no system file dialog can be shown, if it can't (#576); run off the UI thread.
fn no_system_dialog() -> Option<String> {
if std::env::var_os("WORDCRAFT_FILE_DIALOG").is_some_and(|v| v == "builtin") {
return Some("WORDCRAFT_FILE_DIALOG=builtin".into());
}
portal_or_zenity_missing()
}
/// Linux and the BSDs: rfd needs a file chooser portal or zenity. When `dbus-send` can't ask
/// the session bus, the answer is unknown and rfd gets to try.
#[cfg(all(unix, not(target_os = "macos")))]
fn portal_or_zenity_missing() -> Option<String> {
if on_path("zenity") {
return None;
}
let portal = std::process::Command::new("dbus-send")
.args([
"--session",
"--print-reply=literal",
"--reply-timeout=10000",
"--dest=org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.DBus.Properties.Get",
"string:org.freedesktop.portal.FileChooser",
"string:version",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match portal {
Ok(status) if !status.success() => Some("no xdg-desktop-portal FileChooser on the session bus and no zenity".into()),
Ok(_) => None,
Err(e) => {
log::debug!("couldn't ask D-Bus for the file chooser portal: {e}");
None
}
}
}
/// Windows and macOS always have their system dialog.
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn portal_or_zenity_missing() -> Option<String> {
None
}
/// Whether an executable named `program` is in a `PATH` folder.
#[cfg(all(unix, not(target_os = "macos")))]
fn on_path(program: &str) -> bool {
std::env::var_os("PATH").is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(program).is_file()))
}
/// The file types Save As offers: every format the engine saves, with the suggested name's own
/// format first. rfd makes a filter's first extension the dialog's default, which the Windows,
/// macOS and Linux panels append to a file name the user typed without one; with no filter at
/// all the name came back bare and the save failed, since the format is chosen by extension
/// (#689).
fn save_exts(name: &str) -> Vec<String> {
let suggested = std::path::Path::new(name)
.extension()
.and_then(std::ffi::OsStr::to_str)
.map(str::to_ascii_lowercase)
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
.unwrap_or_else(|| "docx".to_string());
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
exts.push(suggested.clone());
exts.extend(wordcraft_engine::io::SAVE_EXTS.iter().filter(|e| **e != suggested).map(|e| (*e).to_string()));
exts
}
/// The "Save as type" list (#1399): one named entry per format, the suggested one first (see
/// [`save_exts`] for why the order matters).
///
/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx"
/// in the name box, the extension in the name wins, as the format is chosen by extension.
fn save_filters(name: &str) -> Vec<(String, Vec<String>)> {
save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect()
}
/// A readable name for a save format's extension.
fn format_name(ext: &str) -> String {
match ext {
"docx" => "Word Document".into(),
"docm" => "Word Macro-Enabled Document".into(),
"dotx" => "Word Template".into(),
"dotm" => "Word Macro-Enabled Template".into(),
"pdf" => "PDF".into(),
"txt" => "Plain Text".into(),
"md" => "Markdown".into(),
"html" => "Web Page".into(),
"rtf" => "Rich Text Format".into(),
"odt" => "OpenDocument Text".into(),
"tex" => "LaTeX".into(),
"png" => "PNG Image".into(),
"json" => "JSON".into(),
"xml" => "XML".into(),
other => other.to_ascii_uppercase(),
}
}
#[cfg(test)]
mod tests {
use super::{pick, save_exts, save_filters};
use std::time::Duration;
/// #2999: a dialog that "cancels" at once is a portal that failed: asked again without a
/// parent, then the built-in browser. A pick, or a cancel after a while, is the answer.
#[test]
fn an_instant_cancel_retries_then_falls_back() {
let quick = Duration::from_millis(200);
let slow_cancel = || async {
// The test polls this future by hand, with no async runtime, so a blocking sleep
// models a slow portal response.
std::thread::sleep(Duration::from_millis(250)); // NOSONAR
Vec::<u8>::new()
};
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
assert_eq!(pick(async { vec![] }, async { vec![2u8] }, quick), Ok(vec![2]), "asked again without a parent");
assert!(pick(async { Vec::<u8>::new() }, async { vec![] }, quick).is_err(), "the built-in browser");
assert_eq!(pick(slow_cancel(), async { vec![2u8] }, quick), Ok(vec![]), "a real cancel isn't asked again");
assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real");
}
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
/// every savable extension exactly once.
#[test]
fn save_dialog_lists_one_named_type_per_format() {
let filters = save_filters("report.odt");
assert_eq!(filters.len(), wordcraft_engine::io::SAVE_EXTS.len());
assert_eq!(filters[0].1, vec!["odt".to_string()]);
assert!(filters[0].0.contains("OpenDocument"), "{}", filters[0].0);
let all: Vec<&String> = filters.iter().flat_map(|(_, e)| e).collect();
for e in wordcraft_engine::io::SAVE_EXTS {
assert_eq!(all.iter().filter(|x| x.as_str() == *e).count(), 1, "{e}");
}
assert!(filters.iter().all(|(l, e)| !l.trim().is_empty() && e.len() == 1));
let mut labels: Vec<&String> = filters.iter().map(|(l, _)| l).collect();
labels.sort();
labels.dedup();
assert_eq!(labels.len(), filters.len(), "labels are distinct");
// Unknown names fall back to Word's format first.
assert_eq!(save_filters("no extension")[0].1, vec!["docx".to_string()]);
}
/// #689: the Save dialog's default extension is the suggested name's own format, so a file
/// name the user types without one is saved in the document's format instead of coming back
/// bare and failing.
#[test]
fn save_dialog_defaults_to_the_suggested_format() {
assert_eq!(save_exts("novel.docx")[0], "docx");
assert_eq!(save_exts("report.odt")[0], "odt");
assert_eq!(save_exts("annual report.pdf")[0], "pdf");
// A suggested name always has an extension; anything unreadable falls back to Word's.
assert_eq!(save_exts("no extension")[0], "docx");
assert_eq!(save_exts("mystery.rtf2")[0], "docx");
// Every savable format is still offered, once.
let exts = save_exts("novel.docx");
assert!(wordcraft_engine::io::SAVE_EXTS.iter().all(|e| exts.iter().any(|x| x == e)));
assert_eq!(exts.len(), wordcraft_engine::io::SAVE_EXTS.len());
/// The file types Open offers: pictures for Insert/Change Picture, recipient lists for Select
/// Recipients › Use an Existing List…, documents otherwise.
fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
if purpose == "picture" {
d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"])
} else if purpose == "recipients" {
d.add_filter("Recipient lists", &["csv", "tsv", "txt"]).add_filter("All files", &["*"])
} else {
d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"])
.add_filter("Word document", &["docx"])
.add_filter("All files", &["*"])
}
}
+22 -150
View File
@@ -1,37 +1,11 @@
//! Graphics instance configuration applied before wgpu can initialize any driver: Windows
//! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip.
// On macOS only the indirect-validation items are used outside tests. Elsewhere it is the other
// way round, so those two carry their own `allow`.
#![cfg_attr(target_os = "macos", allow(dead_code))]
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, DeviceType, Dx12Compiler, InstanceFlags},
wgpu::{Backends, DeviceType},
};
/// wgpu compiles its indirect-call validation compute shaders inside `request_device`. On old
/// Intel Macs (HD Graphics 5000 on Big Sur, #2652) Metal's Intel
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
macos && intel
}
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
/// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) {
if !skip {
return;
}
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
}
}
/// What the start-up probe learned about one DirectX 12 adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Dx12Adapter {
@@ -45,28 +19,18 @@ pub struct Dx12Adapter {
/// The backends to initialize on Windows.
///
/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always
/// wins. Otherwise DirectX 12 only, as long as `dx12_graphics` is set and `dx12_adapters` (what a
/// DirectX 12-only probe found) contains a usable hardware GPU. Without one, OpenGL is the way to
/// the GPU: graphics chips whose driver has no DirectX 12 support (Intel 4th generation on Windows
/// 11, #316) still have an OpenGL driver, and GL can't reach the AMD driver crash described in
/// [`configure_backends`], because an AMD GPU with a driver shows up as a DirectX 12 adapter and
/// keeps us on DirectX 12. When DirectX 12 offers only WARP, it stays enabled next to GL as a last
/// resort for machines without an OpenGL driver either (virtual machines): wgpu prefers any GPU
/// over a software adapter, so it's only picked when GL has nothing better.
///
/// `dx12_graphics` is false on 32-bit Windows. wgpu creates the graphics pipeline through
/// `ID3D12Device2::CreatePipelineState`, and that stream is aligned to 8 bytes. The 32-bit runtime
/// expects pointer alignment (4), so creation returns `E_INVALIDARG` ("The parameter is incorrect")
/// and the process exits before the first frame. Compute pipelines use the older API and succeed,
/// which is why startup gets as far as the egui pipeline and then quits. OpenGL does not use that
/// stream.
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter], dx12_graphics: bool) -> Backends {
/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found)
/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose
/// driver has no DirectX 12 support (Intel 4th generation on Windows 11, #316) still have an
/// OpenGL driver, and GL can't reach the AMD driver crash described in [`configure_backends`],
/// because an AMD GPU with a driver shows up as a DirectX 12 adapter and keeps us on DirectX 12.
/// When DirectX 12 offers only WARP, it stays enabled next to GL as a last resort for machines
/// without an OpenGL driver either (virtual machines): wgpu prefers any GPU over a software
/// adapter, so it's only picked when GL has nothing better.
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter]) -> Backends {
if let Some(backends) = backend_override {
return backends;
}
if !dx12_graphics {
return Backends::GL;
}
let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu;
if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) {
Backends::DX12
@@ -91,54 +55,20 @@ pub fn configure_backends(options: &mut NativeOptions, backends: Backends) {
}
}
/// The DirectX 12 shader compiler: FXC (`d3dcompiler_47.dll`, part of Windows) unless `env`
/// (normally `WGPU_DX12_COMPILER`, wgpu's own variable: `fxc`, `dxc`, `auto`…) names another.
///
/// #603: wgpu's default (`Auto`) uses DXC, loading whichever `dxcompiler.dll` the DLL search path
/// finds first. WordCraft ships none, so that copy belongs to another program. Without a matching
/// `dxil.dll` beside it, DXC warns that the DXIL is unsigned; wgpu takes the warning for a compile
/// error, its own indirect-validation pipeline fails, the device is lost and eframe exits before
/// the first frame. FXC is always there and compiles everything egui's shaders need.
pub fn dx12_compiler(env: Option<&str>) -> Dx12Compiler {
let Some(value) = env else { return Dx12Compiler::Fxc };
value.parse().unwrap_or_else(|e| {
log::warn!("graphics: WGPU_DX12_COMPILER={value:?} ignored ({e}), using FXC");
Dx12Compiler::Fxc
})
}
/// Makes the wgpu instance eframe creates compile DirectX 12 shaders with `compiler`.
pub fn configure_dx12_compiler(options: &mut NativeOptions, compiler: Dx12Compiler) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler;
}
}
/// Chooses the backends ([`choose_backends`]) and applies them to `options`. Without a
/// `backend_override` this first probes DirectX 12 with a short-lived instance of its own, which
/// is dropped before eframe creates the real one.
#[cfg(target_os = "windows")]
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
// First, so the probe's instance doesn't load a stray `dxcompiler.dll` either.
let compiler = dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref());
log::info!("graphics: DirectX 12 shader compiler {compiler:?}");
configure_dx12_compiler(options, compiler);
let backends = match backend_override {
Some(backends) => {
log::info!("graphics: WGPU_BACKEND override, backends {backends:?}");
backends
}
None => {
// 32-bit: skip the DirectX 12 probe. A capable GPU would be selected, then
// CreatePipelineState fails and the app exits with no window.
let dx12_graphics = cfg!(not(target_arch = "x86"));
let adapters = if dx12_graphics { probe_dx12(options) } else { Vec::new() };
let backends = choose_backends(None, &adapters, dx12_graphics);
if dx12_graphics {
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
} else {
log::info!("graphics: 32-bit Windows uses OpenGL; DirectX 12 graphics pipeline creation fails");
}
let adapters = probe_dx12(options);
let backends = choose_backends(None, &adapters);
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
backends
}
};
@@ -157,11 +87,7 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
return Vec::new();
};
let instance = Instance::new(InstanceDescriptor {
backends: Backends::DX12,
backend_options: create.instance_descriptor.backend_options.clone(),
..InstanceDescriptor::new_without_display_handle()
});
let instance = Instance::new(InstanceDescriptor { backends: Backends::DX12, ..InstanceDescriptor::new_without_display_handle() });
pollster::block_on(instance.enumerate_adapters(Backends::DX12))
.iter()
.map(|adapter| {
@@ -178,33 +104,6 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
mod tests {
use super::*;
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
assert!(skips_indirect_validation(true, true));
// Apple silicon, Windows and Linux keep wgpu's validation.
for (macos, intel) in [(true, false), (false, true), (false, false)] {
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
}
let mut options = NativeOptions::default();
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
let before = before.instance_descriptor.flags;
configure_indirect_validation(&mut options, false);
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert_eq!(create.instance_descriptor.flags, before, "flags untouched when not skipping");
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
configure_indirect_validation(&mut options, true);
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(!create.instance_descriptor.flags.contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
@@ -226,7 +125,7 @@ mod tests {
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
assert_eq!(configured_backends(choose_backends(None, &gpu, true)), Backends::DX12);
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
}
#[test]
@@ -234,50 +133,23 @@ mod tests {
let warp_only = [adapter(DeviceType::Cpu, true)];
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
// Whatever the probe would have found, the override wins (the probe doesn't run then).
assert_eq!(choose_backends(Some(backends), &[], true), backends);
assert_eq!(choose_backends(Some(backends), &warp_only, true), backends);
assert_eq!(choose_backends(Some(backends), &[]), backends);
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
assert_eq!(configured_backends(backends), backends);
}
}
/// #603: DirectX 12 shaders compile with FXC, never a `dxcompiler.dll` found on the search
/// path, unless `WGPU_DX12_COMPILER` asks for it.
#[test]
fn dx12_compiles_with_fxc_unless_asked() {
assert!(matches!(dx12_compiler(None), Dx12Compiler::Fxc));
assert!(matches!(dx12_compiler(Some("bogus")), Dx12Compiler::Fxc));
assert!(matches!(dx12_compiler(Some("FXC")), Dx12Compiler::Fxc));
assert!(matches!(dx12_compiler(Some("dxc")), Dx12Compiler::DynamicDxc { .. }));
assert!(matches!(dx12_compiler(Some("auto")), Dx12Compiler::Auto));
let mut options = NativeOptions::default();
configure_dx12_compiler(&mut options, dx12_compiler(None));
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
}
#[test]
fn gl_when_dx12_has_no_usable_gpu() {
// #316: no DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags.
let warp_only = [adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &warp_only, true), Backends::GL | Backends::DX12);
assert_eq!(choose_backends(None, &[], true), Backends::GL);
assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12);
assert_eq!(choose_backends(None, &[]), Backends::GL);
// A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one.
let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &weak_gpu, true), Backends::GL);
assert_eq!(choose_backends(None, &weak_gpu), Backends::GL);
// Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU.
let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)];
assert_eq!(choose_backends(None, &mixed, true), Backends::DX12);
}
/// 32-bit Windows: a usable DirectX 12 GPU still starts on OpenGL. `WGPU_BACKEND` still wins.
#[test]
fn x86_uses_opengl_even_with_a_usable_gpu() {
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
assert_eq!(choose_backends(None, &gpu, false), Backends::GL);
assert_eq!(choose_backends(Some(Backends::DX12), &gpu, false), Backends::DX12);
assert_eq!(choose_backends(Some(Backends::VULKAN), &[], false), Backends::VULKAN);
assert_eq!(configured_backends(choose_backends(None, &gpu, false)), Backends::GL);
assert_eq!(choose_backends(None, &mixed), Backends::DX12);
}
}
+16 -37
View File
@@ -94,10 +94,7 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
/// output never changes for a value the old formatter accepted and logging never panics.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
@@ -106,25 +103,20 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
/// is `num_days_from_ce` 719_163.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -443,21 +435,8 @@ mod tests {
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
// the old formatter gave, rather than falling back to the epoch. FredApps' counterexample:
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+46 -402
View File
@@ -1,6 +1,6 @@
//! WordCraft desktop app.
//!
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`; `--help` prints `USAGE`.
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`
//!
//! `--control <port>` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`.
@@ -9,116 +9,60 @@
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod animations;
#[cfg(target_os = "macos")]
mod apple_events;
mod clipboard;
mod control_server;
mod default_apps;
mod file_dialogs;
#[cfg(any(target_os = "windows", target_os = "macos", test))]
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
mod print;
mod region;
#[cfg(target_os = "macos")]
mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
paste_picture::ClipboardPicture,
window_geometry::{WindowGeometry, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
/// first frame that knows the monitor has checked it, the control server's key file (removed on
/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS,
/// the documents opened from Finder.
/// The app, the restored window geometry until the first frame has checked it, the control
/// server's key file (removed on exit), the file dialogs the app asked for and, on macOS, the
/// documents opened from Finder.
struct App(
WordApp,
Option<WindowGeometry>,
Option<control_server::KeyFile>,
file_dialogs::Launcher,
// The host's answer for this frame. `InputState` still describes the last painted frame on
// a hidden logic tick.
RawWindowState,
#[cfg(target_os = "macos")] fmv_macos_events::Inbox,
);
impl eframe::App for App {
/// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs,
/// fits or rescues the window and maximizes it after opening, then gates a close or quit request
/// on unsaved changes and active printing.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
apple_events::poll(&self.5, &mut self.0, ctx);
apple_events::poll(&self.4, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let window = self.4;
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
// The geometry saved for the next start is the active window's.
let prev = self.0.ui.window;
// Another document window's state is the one it last painted with: the host reports only
// the root window's for this frame.
let active = self.0.active_viewport();
let tracked = if active == egui::ViewportId::ROOT {
window
} else {
ctx.input_for(active, |i| RawWindowState::from_viewport(Some(i.viewport()), Some(i.viewport_rect())))
};
self.0.ui.window = WindowGeometry::track(prev, &tracked);
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
// window that opened at its normal size so it never flashed on screen half-opened.
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
if let Some(pos) = take_rescue(&mut self.1, &window) {
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
if let Some(command) = maximize {
ctx.send_viewport_cmd(command);
}
// The window's close button: with other windows open only this window closes (the app
// handles that while drawing it); the last one quits, asking first about unsaved changes
// (`WordApp::close_requested`).
let closing = ctx.input(|i| i.viewport().close_requested());
if closing && (self.0.closes_a_window() || !self.0.close_requested()) {
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.0.close_requested() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
// `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once
// the close is allowed, take that path instead of dropping the window on this turn.
#[cfg(target_os = "macos")]
{
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.0.quit_requested = false;
schedule_macos_terminate(&mut self.0, self.2.as_ref());
}
#[cfg(not(target_os = "macos"))]
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
/// Records this frame's native window state before forwarding the hook, so logic never reads a
/// stale inner rectangle from a hidden tick.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Keep this frame's native window state. `logic` must not read the last painted
// `InputState` when a hidden Wayland tick reports no fresh inner rect.
self.4 = RawWindowState::from_raw(raw);
self.0.raw_input_hook(ctx, raw);
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.0.ui(ui);
self.3.show(frame);
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
self.0.session.finish_recovery();
save_prefs(&self.0);
if let Some(key_file) = &self.2 {
key_file.remove();
@@ -126,41 +70,6 @@ impl eframe::App for App {
}
}
/// Quit the way Command-Q does, on the next turn of the main queue.
///
/// Called when the window is closing. Doing it on the next turn lets the close
/// button's display cycle finish while the window still exists; `terminate:`
/// then ends the process. Preferences are written and the control key file is
/// removed first, as `on_exit` does, because a fallback [`std::process::exit`]
/// skips `on_exit`. A second close while a quit is already scheduled is ignored.
#[cfg(target_os = "macos")]
fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server::KeyFile>) {
use std::sync::atomic::{AtomicBool, Ordering};
if app.printing.busy() {
return;
}
static QUIT: AtomicBool = AtomicBool::new(false);
if QUIT.swap(true, Ordering::Relaxed) {
return;
}
log::info!("window close: quitting");
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
print::finish_pending_jobs();
app.session.finish_recovery();
save_prefs(app);
if let Some(key_file) = key_file {
key_file.remove();
}
dispatch2::DispatchQueue::main().exec_async(|| {
if let Some(mtm) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
}
// `terminate:` does not return when it succeeds. If it did, leave anyway:
// staying up after the close was cancelled would trap the user in the window.
std::process::exit(0);
});
}
/// `ui.json` in the settings folder, which the MCP bridge also uses to find the control key.
fn prefs_path() -> Option<std::path::PathBuf> {
wordcraft_control_key::settings_dir().map(|b| b.join("ui.json"))
@@ -171,21 +80,6 @@ fn log_dir() -> Option<std::path::PathBuf> {
Some(prefs_path()?.parent()?.join("logs"))
}
/// `custom-dictionary.txt` in the settings folder: the words Add to Dictionary kept (#40).
fn user_dict_path() -> Option<std::path::PathBuf> {
wordcraft_control_key::settings_dir().map(|b| b.join(wordcraft_engine::Session::USER_DICT_FILE))
}
fn load_user_dictionary(app: &mut WordApp) {
if !prefs_enabled() {
return;
}
if let Some(p) = user_dict_path() {
wordcraft_proof::load_user_dictionary(&p);
app.session.user_dict_path = Some(p);
}
}
/// Runs without preferences (`WORDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("WORDCRAFT_NO_PREFS").is_none()
@@ -197,32 +91,19 @@ fn load_prefs(app: &mut WordApp) {
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Some(ui) = UiState::from_json(&bytes)
&& let Ok(ui) = serde_json::from_slice::<UiState>(&bytes)
{
app.apply_prefs(ui);
}
}
/// Fonts the user added (#582) live in `fonts` in the settings folder: load them, and store fonts
/// added later there. Runs without preferences neither read nor keep them.
fn load_user_fonts() {
if !prefs_enabled() {
return;
}
let Some(dir) = wordcraft_control_key::settings_dir().map(|d| d.join("fonts")) else { return };
let (loaded, skipped) = wordcraft_fonts::FontDb::global().load_user_fonts(&dir);
if !loaded.is_empty() || !skipped.is_empty() {
log::info!("user fonts: {} loaded, {} skipped from {}", loaded.len(), skipped.len(), dir.display());
}
}
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
fn saved_window() -> Option<WindowGeometry> {
if !prefs_enabled() {
return None;
}
let bytes = std::fs::read(prefs_path()?).ok()?;
UiState::from_json(&bytes)?.window?.sanitized()
serde_json::from_slice::<UiState>(&bytes).ok()?.window?.sanitized()
}
fn save_prefs(app: &WordApp) {
@@ -230,58 +111,39 @@ fn save_prefs(app: &WordApp) {
return;
}
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
match serde_json::to_vec_pretty(&app.prefs()) {
Ok(bytes) => {
if let Err(e) = write_prefs(&p, &bytes) {
log::warn!("preferences: cannot save {}: {e}", p.display());
}
}
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
if let Ok(bytes) = serde_json::to_vec_pretty(&app.prefs()) {
let _ = std::fs::write(&p, bytes);
}
}
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
/// interrupted write leaves the previous preferences in place.
fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "ui.json".into());
let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id()));
let written = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
/// through the system's PDF viewer (`print`, #15), and pictures on the system clipboard (#45).
fn services(file_dialog: file_dialogs::Hook) -> Services {
Services {
file_dialog: Some(file_dialog),
printers: Some(print::discover()),
print_native: Some(print::submit()),
clipboard_picture: Some(Box::new(clipboard::picture)),
make_default_app: default_apps::hook(),
// Personal templates live in `Templates` in the settings folder (#2852), made on first save.
templates_dir: wordcraft_control_key::settings_dir().map(|d| d.join("Templates")),
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
set_clipboard_html: Some(Box::new(clipboard::set_html)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
print: Some(print::hook()),
clipboard_picture: Some(Box::new(clipboard_picture)),
..Default::default()
}
}
/// The picture on the system clipboard (#45): copied files, else image pixels. The UI checks the
/// sizes before inserting anything.
fn clipboard_picture() -> Option<ClipboardPicture> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()?;
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
let image = clipboard.get_image().ok()?;
Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
}
/// Window, Dock and taskbar icon.
fn app_icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
@@ -291,106 +153,30 @@ fn app_icon() -> Option<egui::IconData> {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
const USAGE: &str = "\
wordcraft — the WordCraft desktop app
USAGE:
wordcraft [--sample] [--control PORT] [FILE…]
OPTIONS:
--sample open the sample document
--control PORT start the localhost control server on PORT, for agents and scripts
(or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md
--version print the version
-h, --help print this help
";
/// The commit the build came from (short), or `dev`.
fn build_sha() -> &'static str {
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")
}
/// The arguments as text, and apart the files whose names aren't valid Unicode, such as a Latin-1
/// one (#1440). A `--control` port that isn't is an error.
fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
let (mut args, mut files) = (Vec::new(), Vec::new());
for (i, a) in std::env::args_os().skip(1).enumerate() {
match a.into_string() {
Ok(a) => args.push(a),
Err(a) if args.last().is_some_and(|p| p == "--control") => {
// Unlike `eprintln!`, never panics.
let (n, lossy) = (i + 1, a.to_string_lossy());
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: argument {n} is not valid Unicode: {lossy}\n"));
std::process::exit(2);
}
Err(a) => files.push(a.into()),
}
}
(args, files)
}
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
let name = path.to_string_lossy();
let opened = wordcraft_engine::io::read_document(path)
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
match opened {
// Not when it waits for a password: that open names the document itself.
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
Ok(_) => {}
Err(e) => log::warn!("{name}: {e}"),
}
}
/// Parses the command line, installs logging and the panic hook, restores preferences and window
/// geometry, opens requested documents or the Start screen, starts the control server when
/// asked, and runs the eframe event loop.
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
logging::install_panic_hook();
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let (args, other_files) = text_args();
let mut bad_control: Option<Option<String>> = None;
let mut args = args.into_iter().peekable();
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
// A flag after `--control` stays a flag; a bad or missing port is an error (#1426).
"--control" => {
let port = args.next_if(|p| p.strip_prefix('-').is_none_or(|n| n.starts_with(|c: char| c.is_ascii_digit())));
match port.as_deref().and_then(|p| p.parse::<std::num::NonZeroU16>().ok()) {
Some(p) => control_port = Some(p.get()),
None => bad_control = Some(port),
}
}
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--version" => {
println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha());
return Ok(());
}
// Before the window opens (#968). Unlike `print!`, a closed stdout can't panic.
"--help" | "-h" => {
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
return Ok(());
}
_ => files.push(a),
}
}
// After the loop, so `--version` later on the line still wins. Unlike `eprintln!`, never panics.
if let Some(port) = bad_control {
let got = port.map(|p| format!(", got {p:?}")).unwrap_or_default();
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: --control needs a port number from 1 to 65535{got}\n"));
std::process::exit(2);
}
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
@@ -421,30 +207,13 @@ fn main() -> eframe::Result {
options.viewport = options.viewport.with_icon(icon);
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when
// DirectX 12 has no GPU driver or this is a 32-bit build, and compile DirectX 12 shaders with
// FXC, which needs no DLL of ours (#603; see graphics.rs).
// DirectX 12 has no GPU driver (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
#[cfg(target_os = "macos")]
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
load_user_fonts();
// Windows opened for further documents start like the first one, before its saved place.
let window_builder = options.viewport.clone();
let restored = saved_window();
if let Some(window) = restored {
options.viewport = window.apply(options.viewport);
}
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
@@ -455,40 +224,12 @@ fn main() -> eframe::Result {
"WordCraft",
options,
Box::new(move |cc| {
// The system region picks the new document's paper, the units and the decimal
// separator (#838).
let region = region::system();
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
app.set_startup_update_check(true);
let mut app = WordApp::new(Session::new(doc), services(file_dialog));
load_prefs(&mut app);
load_user_dictionary(&mut app);
// A region chosen in File › Options replaces the system's for the first blank
// document too (#838).
if !sample && app.session.prefs.region.is_some() {
let doc = app.session.blank_document();
app.session.set_document(doc);
}
app.watch_desktop_theme(&cc.egui_ctx);
if prefs_enabled()
&& let Some(dir) = wordcraft_control_key::settings_dir()
{
let ctx = cc.egui_ctx.clone();
if let Err(e) = app.session.enable_recovery(dir.join("recovery"), std::sync::Arc::new(move || ctx.request_repaint())) {
log::warn!("local recovery unavailable: {e}");
}
}
app.ui.window = restored;
app.set_window_builder(window_builder);
app.integrated_titlebar = cfg!(target_os = "macos");
#[cfg(target_os = "macos")]
if let Some(bar) = traffic_lights::title_bar() {
log::info!("title bar: {bar:?}");
app.mac_titlebar = bar;
}
let mut key_file = None;
if let Some(port) = control_port
&& let Some((rx, file)) = control_server::start(port, cc.egui_ctx.clone(), wordcraft_control_key::settings_dir().as_deref())
@@ -496,116 +237,19 @@ fn main() -> eframe::Result {
app = app.with_control(rx);
key_file = file;
}
// Nothing to open (no files, no sample, no control channel driving the editor): start
// on File › Home, as Word does (#766), unless the Start screen option is off. Files
// macOS hands over later open on top of it and close it (`apple_events`).
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
app.start_screen();
}
for f in files {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
log::warn!("{f}: {e}");
}
}
for f in other_files {
open_non_unicode(&mut app, &f);
}
Ok(Box::new(App(
app,
// With nothing restored, the window the system placed is checked all the same.
Some(restored.unwrap_or_default()),
restored,
key_file,
dialogs,
RawWindowState::default(),
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
}),
)
}
// Not on macOS: APFS refuses file names that aren't valid UTF-8, so no such file can exist there.
#[cfg(all(test, unix, not(target_os = "macos")))]
mod tests {
use super::*;
use std::os::unix::ffi::OsStringExt;
#[test]
fn a_file_whose_name_isnt_unicode_opens_and_saves_back_to_itself() {
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec()));
std::fs::write(&path, "Bonjour").unwrap();
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
open_non_unicode(&mut app, &path);
assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Bonjour");
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
app.run("caret.docEnd", serde_json::json!({})).unwrap();
app.run("text.insert", serde_json::json!({"text": " encore"})).unwrap();
app.run("file.save", serde_json::json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().contains("Bonjour encore"));
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a second file was written");
// A file that can't be read is a warning, as one that fails to open by name is.
open_non_unicode(&mut app, &dir.join(std::ffi::OsString::from_vec(b"gon\xe9.txt".to_vec())));
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
let _ = std::fs::remove_dir_all(dir);
}
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
#[test]
fn a_pipe_or_device_whose_name_isnt_unicode_is_not_read() {
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-dev-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join(std::ffi::OsString::from_vec(b"fif\xe9.txt".to_vec()));
if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
let _ = std::fs::remove_dir_all(&dir);
eprintln!("mkfifo unavailable; skipping");
return;
}
let zero = dir.join(std::ffi::OsString::from_vec(b"z\xe9ro.txt".to_vec()));
std::os::unix::fs::symlink("/dev/zero", &zero).unwrap();
// If the open blocks on the pipe, a writer comes after 10 s, so the test fails rather than hangs.
let unblock = fifo.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(10));
let _ = std::fs::OpenOptions::new().write(true).open(unblock);
});
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap();
for p in [&fifo, &zero] {
let started = std::time::Instant::now();
open_non_unicode(&mut app, p);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{p:?} took {:?}", started.elapsed());
assert_eq!(app.session.path, None, "{p:?}");
assert!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("kept"), "{p:?}");
}
let _ = std::fs::remove_dir_all(dir);
}
}
#[cfg(test)]
mod prefs_tests {
use super::write_prefs;
#[test]
fn failed_preferences_write_keeps_the_old_file_and_reports_the_error() {
let dir = std::env::temp_dir().join(format!("wordcraft-prefs-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ui.json");
std::fs::write(&path, b"old").unwrap();
write_prefs(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
// A directory squatting on the temporary name makes the preparation step fail.
std::fs::create_dir(dir.join(format!(".ui.json.{}.tmp", std::process::id()))).unwrap();
assert!(write_prefs(&path, b"newer").is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&dir);
}
}
+211 -63
View File
@@ -1,81 +1,229 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
//! Desktop printing (#15): File › Print hands the document to the system's print flow.
//!
//! The app renders the document to PDF (`ui.print`, the Print button on File › Print) and calls
//! [`Services::print`] with the bytes. Here they go to a fresh temporary file
//! (`wordcraft-print-….pdf` in the OS temp folder), which is then opened without waiting for it:
//! in Preview on macOS (`open -a Preview`), and in the default PDF app elsewhere (`xdg-open` and
//! friends on Linux and BSD, the file association on Windows, via the `open` crate). The user
//! prints from there through the system print dialog.
//!
//! The viewer reads the file after we return, so it can't be removed right away: files older than
//! [`KEEP`] are cleaned up off the UI thread when the app starts and after each print.
//!
//! [`Services::print`]: wordcraft_ui_egui::Services::print
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
/// sends the list through a channel, keeping spooler calls off the UI thread.
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
let _ = sender.send(printers);
});
receiver
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Temporary print files start with this and end with `.pdf`.
const PREFIX: &str = "wordcraft-print-";
/// How long a print file is kept for its viewer before the clean-up removes it.
const KEEP: Duration = Duration::from_secs(60 * 60);
/// The `Services::print` hook.
pub type Hook = Box<dyn Fn(&[u8]) -> Result<(), String>>;
/// The desktop print hook; also clears print files left over from earlier runs.
pub fn hook() -> Hook {
let dir = std::env::temp_dir();
spawn_cleanup(dir.clone());
Box::new(move |bytes| {
let r = print_with(&dir, bytes, open_viewer);
spawn_cleanup(dir.clone());
r
})
}
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
/// Writes `bytes` to a new print file in `dir` and has `open` show it. When `open` fails the file
/// is removed again.
fn print_with(dir: &Path, bytes: &[u8], open: impl Fn(&Path) -> Result<(), String>) -> Result<(), String> {
let path = write_temp(dir, bytes).map_err(|e| format!("couldn't write the PDF to print: {e}"))?;
open(&path).inspect_err(|_| {
let _ = std::fs::remove_file(&path);
})
}
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
/// A new file in `dir` with a unique print-file name, holding `bytes`. Never overwrites (or
/// follows a link at) an existing path; readable by the user only where the OS supports it.
fn write_temp(dir: &Path, bytes: &[u8]) -> io::Result<PathBuf> {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
for _ in 0..16 {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("{PREFIX}{}-{stamp}-{n}.pdf", std::process::id()));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => {
return match file.write_all(bytes) {
Ok(()) => Ok(path),
Err(e) => {
drop(file);
let _ = std::fs::remove_file(&path);
Err(e)
}
};
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, "no free temporary file name"))
}
/// Removes print files in `dir` last written `keep` or longer before `now`; returns how many.
/// Other files, links and files it may not remove are left alone.
fn remove_stale(dir: &Path, now: SystemTime, keep: Duration) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut removed = 0;
for entry in entries.flatten() {
if !is_print_file(&entry.file_name().to_string_lossy()) {
continue;
}
// `DirEntry::metadata` doesn't follow links.
let Ok(meta) = entry.metadata() else { continue };
let old = meta.modified().ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age >= keep);
if meta.is_file() && old && std::fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
removed
}
fn is_print_file(name: &str) -> bool {
name.starts_with(PREFIX) && name.ends_with(".pdf")
}
/// Runs [`remove_stale`] on a background thread (the temp folder can be large).
fn spawn_cleanup(dir: PathBuf) {
let spawned = std::thread::Builder::new().name("print-cleanup".into()).spawn(move || {
let n = remove_stale(&dir, SystemTime::now(), KEEP);
if n > 0 {
log::info!("removed {n} old print file(s)");
}
});
if let Err(e) = spawned {
log::warn!("print clean-up: {e}");
}
}
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings, collate, options| {
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
copies: settings.copies.clamp(1, 999),
collate,
duplex: settings.duplex.map(|value| match value {
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
Duplex::Off => craft_print::Duplex::Off,
}),
grayscale: settings.grayscale,
scaling: craft_print::Scaling::ShrinkToFit,
title: "WordCraft document".into(),
options,
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
/// Opens the PDF in Preview without waiting for it; if Preview can't open it, the default app.
#[cfg(target_os = "macos")]
fn open_viewer(path: &Path) -> Result<(), String> {
let mut child =
std::process::Command::new("open").arg("-a").arg("Preview").arg(path).spawn().map_err(|e| format!("couldn't open Preview: {e}"))?;
let path = path.to_path_buf();
let waiter = std::thread::Builder::new().name("print-open".into()).spawn(move || {
if !child.wait().is_ok_and(|s| s.success())
&& let Err(e) = open::that(&path)
{
log::error!("print: couldn't open {}: {e}", path.display());
}
receiver
})
});
if let Err(e) = waiter {
log::warn!("print: {e}");
}
Ok(())
}
/// Opens the PDF in the default app without waiting for it (`xdg-open`, `gio`… on Linux and BSD;
/// the file association on Windows).
#[cfg(not(target_os = "macos"))]
fn open_viewer(path: &Path) -> Result<(), String> {
open::that_detached(path).map_err(|e| format!("couldn't open a PDF viewer: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::cell::RefCell;
use std::rc::Rc;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{Services, WordApp};
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wordcraft-print-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// File › Print's Print button (`ui.print`) writes the document's PDF to a print file and
/// hands that file to the viewer (faked here: no process is started); a viewer that can't
/// start is reported in the status bar and leaves no file behind.
#[test]
fn shutdown_waits_for_pending_submission_workers() {
let (release, wait) = std::sync::mpsc::channel();
let (completed, completion) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
wait.recv().unwrap();
completed.send(()).unwrap();
});
JOBS.lock().unwrap().push(worker);
let shutdown = std::thread::spawn(finish_pending_jobs);
assert!(completion.try_recv().is_err(), "the print worker is still pending");
release.send(()).unwrap();
shutdown.join().unwrap();
completion.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
fn file_print_opens_a_pdf_file() {
let dir = temp_dir("open");
let opened: Rc<RefCell<Vec<(PathBuf, Vec<u8>)>>> = Rc::default();
let seen = opened.clone();
let hook_dir = dir.clone();
let services = Services {
print: Some(Box::new(move |bytes| {
print_with(&hook_dir, bytes, |path| {
seen.borrow_mut().push((path.to_path_buf(), std::fs::read(path).map_err(|e| e.to_string())?));
Ok(())
})
})),
..Default::default()
};
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
app.run("text.insert", json!({"text": "Print me"})).unwrap();
app.run("file.print", json!({})).unwrap();
assert!(app.ui.backstage && app.ui.backstage_page == "print", "File › Print opens the Print page");
app.run("ui.print", json!({})).unwrap();
app.run("ui.print", json!({})).unwrap();
let opened = opened.borrow();
assert_eq!(opened.len(), 2, "each print opens the viewer once");
for (path, bytes) in opened.iter() {
assert!(bytes.starts_with(b"%PDF"), "the viewer gets a PDF");
assert_eq!(path.parent(), Some(dir.as_path()));
assert!(is_print_file(&path.file_name().unwrap().to_string_lossy()), "{}", path.display());
}
assert_ne!(opened[0].0, opened[1].0, "every print gets its own file");
let mut app = WordApp::new(
Session::new(wordcraft_doc::Document::new()),
Services {
print: Some(Box::new({
let dir = dir.clone();
move |bytes| print_with(&dir, bytes, |_| Err("no PDF viewer".into()))
})),
..Default::default()
},
);
let e = app.run("ui.print", json!({})).unwrap_err();
assert!(e.contains("no PDF viewer"), "{e}");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "the failed print's file is removed");
let _ = std::fs::remove_dir_all(&dir);
}
/// The clean-up removes old print files only.
#[test]
fn stale_print_files_are_removed() {
let dir = temp_dir("stale");
let fresh = write_temp(&dir, b"%PDF-1.7").unwrap();
let old = write_temp(&dir, b"%PDF-1.7").unwrap();
let hour_ago = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
OpenOptions::new().write(true).open(&old).unwrap().set_modified(hour_ago).unwrap();
let other = dir.join("notes.pdf");
std::fs::write(&other, b"keep").unwrap();
OpenOptions::new().write(true).open(&other).unwrap().set_modified(hour_ago).unwrap();
assert_eq!(remove_stale(&dir, SystemTime::now(), KEEP), 1);
assert!(fresh.exists() && other.exists() && !old.exists());
assert_eq!(remove_stale(&dir.join("missing"), SystemTime::now(), KEEP), 0, "a missing folder is fine");
let _ = std::fs::remove_dir_all(&dir);
}
}
-30
View File
@@ -1,30 +0,0 @@
//! The system region new documents follow (#838): the paper File › New starts on, the default
//! measurement unit and the decimal separator.
//!
//! - Windows: the preferred language with the home location from Settings › Time & language ›
//! Region (`GlobalizationPreferences.HomeGeographicRegion`, a safe Windows Runtime API), since
//! the language list alone says `en-US` for an English interface in Poland.
//! - Elsewhere: the locale the UI crate reads ([`wordcraft_ui_egui::i18n::system_region`]).
/// The system region.
pub fn system() -> wordcraft_engine::Region {
// Runs without preferences (agents' and screenshot runs) behave the same on every machine:
// en-US unless `WORDCRAFT_REGION` says otherwise.
if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() && std::env::var_os("WORDCRAFT_REGION").is_none() {
return wordcraft_engine::Region::default();
}
let region = wordcraft_ui_egui::i18n::system_region();
#[cfg(target_os = "windows")]
{
if std::env::var_os("WORDCRAFT_REGION").is_none() {
return match windows::System::UserProfile::GlobalizationPreferences::HomeGeographicRegion() {
Ok(home) => region.with_territory(&home.to_string()),
Err(e) => {
log::debug!("system home region: {e}");
region
}
};
}
}
region
}
-26
View File
@@ -1,26 +0,0 @@
//! Where AppKit puts the traffic lights, so the integrated title bar fits around them (macOS).
//!
//! The lights' size and position change between macOS versions (they grew in macOS 26), so the
//! title bar measures the real buttons instead of assuming the old 28 pt layout. These calls are
//! safe in the `objc2-app-kit` bindings (our workspace forbids unsafe).
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSWindowButton};
use wordcraft_ui_egui::chrome::MacTitleBar;
/// The title bar around the app window's traffic lights, or `None` before the window exists or
/// when AppKit has no zoom button to measure (the caller keeps the standard layout).
pub fn title_bar() -> Option<MacTitleBar> {
let mtm = MainThreadMarker::new()?;
let window = NSApplication::sharedApplication(mtm).windows().firstObject()?;
let zoom = window.standardWindowButton(NSWindowButton::ZoomButton)?;
// In window coordinates, which count up from the bottom.
let frame = zoom.convertRect_toView(zoom.bounds(), None);
let window_height = window.frame().size.height;
let right = frame.origin.x + frame.size.width;
let centre_from_top = window_height - (frame.origin.y + frame.size.height / 2.0);
if !(right.is_finite() && centre_from_top.is_finite() && right > 0.0 && centre_from_top > 0.0) {
return None;
}
Some(MacTitleBar::around_lights(right as f32, centre_from_top as f32, frame.size.height as f32))
}
-58
View File
@@ -1,58 +0,0 @@
//! `wordcraft --control` checks its port before the window opens (#1426).
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(args: &[&str]) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
// A bad `--control` must not fall back to this port either.
.env("WORDCRAFT_CONTROL_PORT", "7981")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
#[test]
fn a_bad_port_is_an_error_naming_it() {
let cases: [(&[&str], &str); 8] = [
(&["--control", "-99999999999999999999"], ", got \"-99999999999999999999\""),
(&["--control", "abc"], ", got \"abc\""),
(&["--control", "79811"], ", got \"79811\""),
(&["--control", "0"], ", got \"0\""),
(&["--control", "-5"], ", got \"-5\""),
(&["--control", "abc", "--control", "7981"], ", got \"abc\""),
(&["--control"], ""),
(&["--control", "--sample"], ""),
];
for (args, got) in cases {
let out = app(args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{args:?}: stderr: {stderr}");
assert_eq!(stderr, format!("wordcraft: --control needs a port number from 1 to 65535{got}\n"), "{args:?}");
}
}
#[test]
fn a_flag_after_control_is_read_as_a_flag() {
let out = app(&["--control", "--version"]);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
}
-42
View File
@@ -1,42 +0,0 @@
//! `wordcraft --help` and `-h` print the options and exit without opening a window (#968).
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(flag: &str) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.arg(flag)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {flag} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
#[test]
fn help_and_h_print_the_options_and_exit() {
for flag in ["--help", "-h"] {
let out = app(flag);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "{flag}: status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft — "), "{flag}: stdout: {stdout}");
for option in ["--sample", "--control PORT", "--version", "-h, --help"] {
assert!(stdout.contains(option), "{flag}: {option} missing from: {stdout}");
}
assert!(stderr.is_empty(), "{flag}: stderr: {stderr}");
}
}
-4
View File
@@ -1,4 +0,0 @@
mod control_arg;
mod help;
#[cfg(unix)]
mod non_utf8_args;
-52
View File
@@ -1,52 +0,0 @@
//! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440).
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(args: &[OsString]) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
/// The file opens once the window is up (`open_non_unicode`, unit-tested in `main.rs`). Not on
/// macOS, where the app opens its window without a display variable and so keeps running.
#[cfg(not(target_os = "macos"))]
#[test]
fn a_file_with_such_a_name_starts_the_app() {
let out = app(&["--sample".into(), OsString::from_vec(b"caf\xe9.docx".to_vec())]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!stderr.contains("panic"), "stderr: {stderr}");
// It went on to open the window, which fails without a display.
assert!(stderr.contains("WinitEventLoop"), "stderr: {stderr}");
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
}
#[test]
fn a_control_port_that_isnt_unicode_is_an_error_naming_it() {
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
assert_eq!(stderr, "wordcraft: argument 3 is not valid Unicode: 79\u{FFFD}81\n");
}
File diff suppressed because it is too large Load Diff
-431
View File
@@ -1,431 +0,0 @@
% title: Hyphenation patterns for Italian
% copyright: Copyright (C) 2008-2011 Claudio Beccari
% notice: This file is part of the hyph-utf8 package.
% See http://www.hyphenation.org/tex for more information.
% language:
% name: Italian
% tag: it
% version: 4.9 2014/04/22
% authors:
% -
% name: Claudio Beccari
% contact: claudio.beccari (at) gmail.com
% licence:
% - This file is available under any of the following licences:
% -
% name: LPPL
% version: 1.3
% or_later: true
% url: http://www.latex-project.org/lppl.txt
% status: maintained
% maintainer: Claudio Beccari, e-mail claudio dot beccari at gmail dot com
% -
% name: MIT
% url: https://opensource.org/licenses/MIT
% text: >
% Permission is hereby granted, free of charge, to any person
% obtaining a copy of this software and associated documentation
% files (the "Software"), to deal in the Software without
% restriction, including without limitation the rights to use,
% copy, modify, merge, publish, distribute, sublicense, and/or sell
% copies of the Software, and to permit persons to whom the
% Software is furnished to do so, subject to the following
% conditions:
%
% The above copyright notice and this permission notice shall be
% included in all copies or substantial portions of the Software.
%
% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
% OTHER DEALINGS IN THE SOFTWARE.
% hyphenmins:
% typesetting:
% left: 2
% right: 2
% changes:
% - 2014-04-22 - Add few patterns involving `h'
% - 2011-08-16 - Change the licence from GNU LGPL into LPPL v1.3.
% - 2010-05-24 - Fix for Italian patterns for proper hyphenation of -ich and Ljubljana.
% - 2008-06-09 - Import of original ithyph.tex into hyph-utf8 package.
% - 2008-03-08 - (last change in ithyph.tex)
% texlive:
% encoding: ascii
% babelname: italian
% legacy_patterns: ithyph.tex
% message: Italian hyphenation patterns
% description: |-
% Hyphenation patterns for Italian in ASCII encoding.
% Compliant with the Recommendation UNI 6461 on hyphenation
% issued by the Italian Standards Institution
% (Ente Nazionale di Unificazione UNI).
% ==========================================
%
% These hyphenation patterns for the Italian language are supposed to comply
% with the Recommendation UNI 6461 on hyphenation issued by the Italian
% Standards Institution (Ente Nazionale di Unificazione UNI). No guarantee
% or declaration of fitness to any particular purpose is given and any
% liability is disclaimed.
%
\patterns{
.a3p2n % After the Garzanti dictionary: a-pnea, a-pnoi-co,...
.anti1
.anti3m2n
.bio1
.ca4p3s
.circu2m1
.contro1
.di2s3cine
.e2x1eu
.fran2k3
.free3
.li3p2sa
.narco1
.opto1
.orto3p2
.para1
.ph2l
.ph2r
.poli3p2
.pre1
.p2s
.re1i2scr
.sha2re3
.tran2s3c
.tran2s3d
.tran2s3l
.tran2s3n
.tran2s3p
.tran2s3r
.tran2s3t
.su2b3lu
.su2b3r
.wa2g3n
.wel2t1
2'2
a1ia
a1ie
a1io
a1iu
a1uo
a1ya
2at.
e1iu
e2w
o1ia
o1ie
o1io
o1iu
1b
2bb
2bc
2bd
2bf
2bm
2bn
2bp
2bs
2bt
2bv
b2l
b2r
2b.
2b'
1c
2cb
2cc
2cd
2cf
2ck
2cm
2cn
2cq
2cs
2ct
2cz
2chh
c2h
2ch.
2ch'.
2ch''.
2chb
ch2r
2chn
c2l
c2r
2c.
2c'
.c2
1d
2db
2dd
2dg
2dl
2dm
2dn
2dp
d2r
2ds
2dt
2dv
2dw
2d.
2d'
.d2
1f
2fb
2fg
2ff
2fn
f2l
f2r
2fs
2ft
2f.
2f'
1g
2gb
2gd
2gf
2gg
g2h
g2l
2gm
g2n
2gp
g2r
2gs
2gt
2gv
2gw
2gz
2gh2t
2g.
2g'
.h2
1h
2hb
2hd
2hh
hi3p2n
h2l
2hm
2hn
2hr
2hv
2h.
2h'
.j2
1j
2j.
2j'
.k2
1k
2kg
2kf
k2h
2kk
k2l
2km
k2r
2ks
2kt
2k.
2k'
1l
2lb
2lc
2ld
2l3f2
2lg
l2h
l2j
2lk
2ll
2lm
2ln
2lp
2lq
2lr
2ls
2lt
2lv
2lw
2lz
2l.
2l'.
2l''
1m
2mb
2mc
2mf
2ml
2mm
2mn
2mp
2mq
2mr
2ms
2mt
2mv
2mw
2m.
2m'
1n
2nb
2nc
2nd
2nf
2ng
2nk
2nl
2nm
2nn
2np
2nq
2nr
2ns
n2s3fer
2nt
2nv
2nz
n2g3n
2nheit
2n.
2n'
1p
2pd
p2h
p2l
2pn
3p2ne
2pp
p2r
2ps
3p2sic
2pt
2pz
2p.
2p'
1q
2qq
2q.
2q'
1r
2rb
2rc
2rd
2rf
r2h
2rg
2rk
2rl
2rm
2rn
2rp
2rq
2rr
2rs
2rt
r2t2s3
2rv
2rx
2rw
2rz
2r.
2r'
1s2
2shm
2sh.
2sh'
2s3s
s4s3m
2s3p2n
2stb
2stc
2std
2stf
2stg
2stm
2stn
2stp
2sts
2stt
2stv
2sz
4s.
4s'.
4s''
.t2
1t
2tb
2tc
2td
2tf
2tg
t2h
2th.
t2l
2tm
2tn
2tp
t2r
t2s
3t2sch
2tt
t2t3s
2tv
2tw
t2z
2tzk
tz2s
2t.
2t'.
2t''
1v
2vc
v2l
v2r
2vv
2v.
2v'.
2v''
1w
w2h
wa2r
2w1y
2w.
2w'
1x
2xb
2xc
2xf
2xh
2xm
2xp
2xt
2xw
2x.
2x'
y1ou
y1i
1z
2zb
2zd
2zl
2zn
2zp
2zt
2zs
2zv
2zz
2z.
2z'.
2z''
.z2
} % Pattern end
File diff suppressed because it is too large Load Diff
-503
View File
@@ -1,503 +0,0 @@
% title: Hyphenation patterns for Portuguese
% copyright: Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, 2024 Leonardo Araujo and Aline Benevides
% notice: This file is part of the hyph-utf8 package.
% See http://www.hyphenation.org/tex for more information.
% language:
% name: Portuguese
% tag: pt
% version: 1.4 2024-07-13
% authors:
% -
% name: Pedro J. de Rezende
% contact: rezende (at) ic.unicamp.br
% -
% name: J. Joao Dias Almeida
% contact: jj (at) di.uminho.pt
% -
% name: Leonardo Araujo
% contact: leolca (at) gmail.com
% -
% name: Aline Benevides
% contact: benevides.aline12 (at) gmail.com
% licence:
% name: BSD 3-clause licence
% url: https://opensource.org/licenses/BSD-3-Clause
% text: >
% Redistribution and use in source and binary forms, with or without
% modification, are permitted provided that the following conditions
% are met:
% * Redistributions of source code must retain the above copyright
% notice, this list of conditions and the following disclaimer.
% * Redistributions in binary form must reproduce the above copyright
% notice, this list of conditions and the following disclaimer in the
% documentation and/or other materials provided with the
% distribution.
% * Neither the name of the University of Campinas, of the University
% of Minho nor the names of its contributors may be used to endorse
% or promote products derived from this software without specific
% prior written permission.
%
% THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
% "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
% LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
% A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO J. DE
% REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
% INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
% BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
% OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
% AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
% LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
% WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
% POSSIBILITY OF SUCH DAMAGE.
% hyphenmins:
% typesetting:
% left: 2
% right: 3
% changes:
% - Version 1.4 Release date: 13/07/2024 Leonardo Araujo and Aline Benevides
% - Version 1.3 Release date: 12/08/2015 Pedro J. de Rezende and J. Joao Dias Almeida
% - Version 1.2 Release date: 07/21/1996 Pedro J. de Rezende and J. Joao Dias Almeida
% - Version 1.1 Release date: 04/12/1994 Pedro J. de Rezende
% - Version 1.0 Release date: 02/13/1987 Pedro J. de Rezende
% texlive:
% synonyms:
% - portuges
% encoding: ec
% babelname: portuguese
% legacy_patterns: pthyph.tex
% message: Portuguese hyphenation patterns
% description: Hyphenation patterns for Portuguese in T1/EC and UTF-8 encodings.
%
\patterns{
1b2l
1b2r
1ba
1be
1bi
1bo
1bu
1bá
1bâ
1bã
1bé
1bí
1bó
1bú
1bê
1bõ
1c2h
1c2l
1c2r
1ca
1ce
1ci
1co
1cu
1cá
1câ
1cã
1cé
1cí
1có
1cú
1cê
1cõ
1ça
1çe
1çi
1ço
1çu
1çá
1çâ
1çã
1çé
1çí
1çó
1çú
1çê
1çõ
1d2l
1d2r
1da
1de
1di
1do
1du
1dá
1dâ
1dã
1dé
1dí
1dó
1dú
1dê
1dõ
1f2l
1f2r
1fa
1fe
1fi
1fo
1fu
1fá
1fâ
1fã
1fé
1fí
1fó
1fú
1fê
1fõ
1g2l
1g2r
1ga
1ge
1gi
1go
1gu
1gu4a
1gu4e
1gu4i
1gu4o
1gá
1gâ
1gã
1gé
1gí
1gó
1gú
1gê
1gõ
1ja
1je
1ji
1jo
1ju
1já
1jâ
1jã
1jé
1jí
1jó
1jú
1jê
1jõ
1k2l
1k2r
1ka
1ke
1ki
1ko
1ku
1ká
1kâ
1kã
1ké
1kí
1kó
1kú
1kê
1kõ
1l2h
1la
1le
1li
1lo
1lu
1lá
1lâ
1lã
1lé
1lí
1ló
1lú
1lê
1lõ
1ma
1me
1mi
1mo
1mu
1má
1mâ
1mã
1mé
1mí
1mó
1mú
1mê
1mõ
1n2h
1na
1ne
1ni
1no
1nu
1ná
1nâ
1nã
1né
1ní
1nó
1nú
1nê
1nõ
1p2l
1p2r
1pa
1pe
1pi
1po
1pu
1pá
1pâ
1pã
1pé
1pí
1pó
1pú
1pê
1põ
1qu4a
1qu4e
1qu4i
1qu4o
1ra
1re
1ri
1ro
1ru
1rá
1râ
1rã
1ré
1rí
1ró
1rú
1rê
1rõ
1sa
1se
1si
1so
1su
1sá
1sâ
1sã
1sé
1sí
1só
1sú
1sê
1sõ
1t2l
1t2r
1ta
1te
1ti
1to
1tu
1tá
1tâ
1tã
1té
1tí
1tó
1tú
1tê
1tõ
1v2l
1v2r
1va
1ve
1vi
1vo
1vu
1vá
1vâ
1vã
1vé
1ví
1vó
1vú
1vê
1võ
1w2l
1w2r
1xa
1xe
1xi
1xo
1xu
1xá
1xâ
1xã
1xé
1xí
1xó
1xú
1xê
1xõ
1za
1ze
1zi
1zo
1zu
1zá
1zâ
1zã
1zé
1zí
1zó
1zú
1zê
1zõ
a3a
a3e
a3o
c3c
e3a
e3e
e3o
i3a
i3e
i3i
i3o
i3â
i3ê
i3ô
o3a
o3e
o3o
r3r
s3s
u3a
u3e
u3o
u3u
.p2si
.p2sí
.g2no
.g2nó
.g2nô
t2c
1p2neu
.t2m
.p2t
su2b3r
su2b3l
.su3b4li
a1ir.
u1ir.
1qu
1vô
1lô
1cô
1gô
1bô
1tô
1rô
1pô
a1é
a1í
a1ó
a1ú
e1á
e1â
e1ã
e1é
e1ê
e1í
e1ó
é1o
e1ú
i1á
i1ã
í1a
i1é
i1í
i1ó
í1o
i1u
i1ú
o1á
o1é
o1í
o1ó
u1á
u1ã
u1â
u1í
ú1o
1qu2á
1qu2â
1qu2í
1gu2í
a1ind
a1i1nh
e1imp
e1inc
e1inf
e1ing
e1ins
e1int
e1inv
u1iz.
a1iz.
4a.
4e.
4o.
1gu2á
1gu2ã
1qu2ã
.m2n
c2za
.s2
1p2seu1d
1dô
1fô
1mô
1nô
1sô
1zô
tu1i
tu2it
tu2id
bu1i
nu1i
o1in
u1in
su1i
í1e
ju1i
fu1i
du1i
do1im
au1i
u1i1ç
u1ê
1gu2ê
1qu2ê
1çô
u1é
1gu2é
1qu2é
1xô
a1â
a1ã
a1ô
e1ô
.ne4o
o1ã
o1ê
o1im
o2i1na
pro1i1b
co2ima
.t2
1-
}
\hyphenation{% Do NOT make any alterations to this list! --- PdR
hard-ware
soft-ware
}
-241
View File
@@ -1,241 +0,0 @@
# English spelling supplement: common words the public-domain Moby list (1990s) lacks.
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
# One lowercase base form per line, sorted. Plurals, -ed, -ing, -er, -ly… come from the
# inflection rules in crates/proof/src/lib.rs, so list "desktop", not "desktops".
airbag
airfare
artcraft
autocomplete
autocorrect
autofill
autoformat
autosave
backend
bitcoin
bitmap
blockchain
blog
blogger
bluetooth
captcha
caregiver
carpool
cellphone
chatbot
chatroom
checkbox
checklist
childcare
clamour
clickable
colourful
colourisation
colourise
colourless
configurable
copyedit
coronavirus
covid
crowdfunding
crowdsource
crypto
cryptocurrency
customizable
customization
cyber
cybersecurity
cyberspace
database
dataset
daycare
desktop
download
downloadable
downsize
dropdown
earbud
ebook
ecommerce
email
emoji
emoticon
endeavour
esports
facebook
favicon
fintech
firewall
flowchart
foodie
freeware
frontend
fundraiser
gameplay
gamification
geeky
geolocation
gigabit
gigabyte
github
glamour
globalization
google
groupthink
hackathon
hardcode
hashtag
healthcare
homepage
homeschool
honour
honourable
honourably
hotspot
hyperlink
hypertext
inbox
incentivize
infographic
instagram
internet
interoperability
intranet
ipad
iphone
javascript
kickstart
kilobyte
laptop
lifestyle
linkedin
linux
livestream
lockdown
login
logout
lowercase
makeover
malware
manoeuvrable
megabyte
megapixel
meme
metadata
microchip
microsoft
middleware
mindset
multicultural
multiplayer
multitask
nerdy
netbook
netflix
newsfeed
offboard
offline
onboard
online
onscreen
outsource
paediatric
paralegal
passcode
petabyte
phishing
photobomb
playdate
playlist
plugin
podcast
popup
prequel
printout
prioritise
prioritization
prioritize
privatise
ransomware
recyclable
rigour
ringtone
roadmap
rollout
runtime
scalability
screencast
screensaver
screenshot
scrollbar
selfie
signup
sitemap
skillset
smartphone
smartwatch
smiley
snowboard
spellcheck
spellchecker
spreadsheet
spyware
stakeholder
standup
startup
subfolder
sunscreen
superfood
superuser
synthesise
tagline
takeout
techie
telecommute
teleconference
telehealth
telemedicine
terabyte
textbox
timeline
timestamp
toolbar
toolkit
touchpad
touchscreen
trackball
trackpad
travelogue
upcycle
upload
uppercase
upsell
upskill
usability
username
videoconference
videogame
vlog
vlogger
voicemail
walkthrough
webcam
webcast
webinar
weblog
webmaster
webpage
website
whatsapp
whiteboard
wifi
wiki
wikipedia
wireframe
wordcraft
workaround
workflow
workforce
workplace
workspace
workstation
youtube
-305
View File
@@ -1,305 +0,0 @@
# English spelling variants: American and British forms of the same word, for the English
# dialects (#1281, #1045). Written by WordCraft contributors from general knowledge of English
# spelling (MIT OR Apache-2.0); no other word list copied. See ATTRIBUTION.md.
#
# Each line: POLICY AMERICAN BRITISH
# POLICY is three letters, for American English (en-US), Canadian English (en-CA) and the
# British-spelling Englishes (en-GB, en-AU, en-NZ, en-IE, en-ZA, en-IN…), in that order:
# u only the American form is right there (the British one is flagged, American suggested)
# b only the British form is right there
# * both are right
# Inflections (colours, centred, organising, organisation…) come from the rules in
# crates/proof/src/en_variants.rs for -or/-our, -er/-re and -ize/-ise style pairs; other pairs
# get only a plural, so list their other forms on lines of their own. When in doubt a form is
# accepted: a false alarm is worse than a missed variant.
# -or / -our
ubb arbor arbour
ubb ardor ardour
ubb armor armour
ubb armory armoury
ubb behavior behaviour
ubb behavioral behavioural
ubb candor candour
ubb clamor clamour
ubb color colour
ubb demeanor demeanour
ubb discolor discolour
ubb dishonor dishonour
ubb endeavor endeavour
ubb favor favour
ubb favorite favourite
ubb favoritism favouritism
ubb fervor fervour
ubb flavor flavour
ubb harbor harbour
ubb honor honour
ubb humor humour
ubb labor labour
ubb misbehavior misbehaviour
ubb neighbor neighbour
ubb neighborhood neighbourhood
ubb neighborly neighbourly
ubb odor odour
ubb parlor parlour
ubb rancor rancour
ubb rigor rigour
ubb rumor rumour
ubb savior saviour
ubb savor savour
ubb savory savoury
ubb splendor splendour
ubb succor succour
ubb tumor tumour
ubb unfavorable unfavourable
ubb valor valour
ubb vapor vapour
ubb vigor vigour
# -er / -re
ubb caliber calibre
ubb center centre
ubb centimeter centimetre
ubb epicenter epicentre
ubb fiber fibre
ubb kilometer kilometre
ubb liter litre
ubb luster lustre
ubb maneuver manoeuvre
ubb meager meagre
ubb millimeter millimetre
ubb miter mitre
ubb ocher ochre
ubb saber sabre
ubb sepulcher sepulchre
ubb somber sombre
ubb specter spectre
*bb theater theatre
# A meter is also a measuring device in British English.
u** meter metre
# -ize / -ise (British English accepts both: Oxford spelling uses -ize)
uu* agonize agonise
uu* apologize apologise
uu* authorize authorise
uu* capitalize capitalise
uu* categorize categorise
uu* centralize centralise
uu* characterize characterise
uu* civilize civilise
uu* colonize colonise
uu* commercialize commercialise
uu* computerize computerise
uu* criticize criticise
uu* crystallize crystallise
uu* customize customise
uu* decentralize decentralise
uu* demonize demonise
uu* deodorize deodorise
uu* destabilize destabilise
uu* digitize digitise
uu* dramatize dramatise
uu* economize economise
uu* emphasize emphasise
uu* energize energise
uu* equalize equalise
uu* familiarize familiarise
uu* fantasize fantasise
uu* fertilize fertilise
uu* finalize finalise
uu* formalize formalise
uu* fossilize fossilise
uu* galvanize galvanise
uu* generalize generalise
uu* globalize globalise
uu* harmonize harmonise
uu* hospitalize hospitalise
uu* humanize humanise
uu* hypnotize hypnotise
uu* hypothesize hypothesise
uu* idealize idealise
uu* idolize idolise
uu* immobilize immobilise
uu* immortalize immortalise
uu* immunize immunise
uu* industrialize industrialise
uu* initialize initialise
uu* italicize italicise
uu* itemize itemise
uu* jeopardize jeopardise
uu* legalize legalise
uu* legitimize legitimise
uu* liberalize liberalise
uu* localize localise
uu* magnetize magnetise
uu* marginalize marginalise
uu* maximize maximise
uu* mechanize mechanise
uu* memorize memorise
uu* mesmerize mesmerise
uu* minimize minimise
uu* mobilize mobilise
uu* modernize modernise
uu* monopolize monopolise
uu* moralize moralise
uu* nationalize nationalise
uu* naturalize naturalise
uu* neutralize neutralise
uu* normalize normalise
uu* optimize optimise
uu* organize organise
uu* ostracize ostracise
uu* oxidize oxidise
uu* pasteurize pasteurise
uu* patronize patronise
uu* penalize penalise
uu* personalize personalise
uu* plagiarize plagiarise
uu* polarize polarise
uu* popularize popularise
uu* pressurize pressurise
uu* prioritize prioritise
uu* privatize privatise
uu* publicize publicise
uu* pulverize pulverise
uu* randomize randomise
uu* rationalize rationalise
uu* realize realise
uu* recognize recognise
uu* reorganize reorganise
uu* revolutionize revolutionise
uu* satirize satirise
uu* scrutinize scrutinise
uu* sensitize sensitise
uu* socialize socialise
uu* specialize specialise
uu* stabilize stabilise
uu* standardize standardise
uu* sterilize sterilise
uu* stigmatize stigmatise
uu* subsidize subsidise
uu* summarize summarise
uu* symbolize symbolise
uu* sympathize sympathise
uu* synchronize synchronise
uu* synthesize synthesise
uu* systematize systematise
uu* tantalize tantalise
uu* terrorize terrorise
uu* theorize theorise
uu* trivialize trivialise
uu* urbanize urbanise
uu* utilize utilise
uu* vandalize vandalise
uu* verbalize verbalise
uu* victimize victimise
uu* visualize visualise
uu* vocalize vocalise
uu* westernize westernise
# -yze / -yse
u*b analyze analyse
u*b catalyze catalyse
u*b dialyze dialyse
u*b electrolyze electrolyse
u*b hydrolyze hydrolyse
u*b paralyze paralyse
u*b psychoanalyze psychoanalyse
# -ense / -ence
ubb defense defence
ubb offense offence
ubb pretense pretence
ubb defenseless defenceless
# In British English "license" and "practise" are the verbs, so only American English flags.
u** license licence
u** practice practise
# Doubled l (American English accepts both)
*bb canceled cancelled
*bb canceling cancelling
*bb channeled channelled
*bb channeling channelling
*bb counseled counselled
*bb counseling counselling
*bb counselor counsellor
*bb counselors counsellors
*bb dueling duelling
*bb equaled equalled
*bb equaling equalling
*bb fueled fuelled
*bb fueling fuelling
*bb jeweler jeweller
*bb jewelers jewellers
*bb jewelry jewellery
*bb labeled labelled
*bb labeling labelling
*bb leveled levelled
*bb leveling levelling
*bb marveled marvelled
*bb marveling marvelling
*bb marvelous marvellous
*bb modeled modelled
*bb modeling modelling
*bb quarreled quarrelled
*bb quarreling quarrelling
*bb signaled signalled
*bb signaling signalling
*bb totaled totalled
*bb totaling totalling
*bb traveled travelled
*bb traveler traveller
*bb travelers travellers
*bb traveling travelling
*bb tunneled tunnelled
*bb tunneling tunnelling
*bb woolen woollen
# -og / -ogue
*bb catalog catalogue
*bb cataloged catalogued
*bb cataloging cataloguing
# ae / oe
u*b anemia anaemia
u*b anemic anaemic
u*b anesthesia anaesthesia
u*b anesthetic anaesthetic
**b archeology archaeology
**b archeologist archaeologist
u*b diarrhea diarrhoea
u*b edema oedema
u*b esophagus oesophagus
u*b estrogen oestrogen
u*b gynecologist gynaecologist
u*b gynecology gynaecology
u*b hematology haematology
u*b hemoglobin haemoglobin
u*b hemorrhage haemorrhage
u*b leukemia leukaemia
u*b orthopedic orthopaedic
u*b pediatric paediatric
u*b pediatrician paediatrician
u*b pediatrics paediatrics
# Other words
u*b aluminum aluminium
u*b cozy cosy
ubb mold mould
ubb molded moulded
ubb molding moulding
ubb moldy mouldy
ubb molt moult
ubb pajamas pyjamas
ubb plow plough
ubb plowed ploughed
ubb plowing ploughing
u** program programme
u*b skeptic sceptic
u*b skeptical sceptical
u*b skepticism scepticism
ubb smolder smoulder
ubb smoldering smouldering
# "Tire" and "curb" are also verbs everywhere, so only American English flags.
uu* tire tyre
uu* curb kerb
-214
View File
@@ -1,214 +0,0 @@
# Russian spelling supplement: common words older Russian Hunspell dictionaries lack.
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
# One word per line: the base form, a TAB, then its declension, from which
# crates/proof/src/ru/extra.rs makes every form:
# m masculine noun ending in a consonant (маркетплейс, -а, -у, -ом, -е, -ы/-и, -ов/-ей…)
# f feminine noun in -а (инфографика, -и, -е, -у, -ой…; genitive plural without ending)
# fia feminine noun in -ия (интеграция, -ии, -ию, -ией, -ий…)
# fs feminine noun in -ь (соцсеть, -и, -ью, -ей, -ям…)
# nie neuter noun in -ие (согласие, -ия, -ию, -ием, -ий…)
# no neuter noun in -о (юрлицо, -а, -у, -ом, -е; genitive plural without ending)
# adj adjective or participle in -ый, -ий, -ой (бонусный, газоиспользующий)
# ovat verb in -овать with its conjugation and participles (колеровать, администрировать)
# = the forms themselves, space-separated (verbs, nouns with a fleeting vowel)
# A word alone on its line doesn't decline (онлайн, медиа). Capitalized words are names and match
# only when written with a capital.
# Commerce, marketing and the web
маркетплейс m
селлер m
фулфилмент m
ритейл m
ритейлер m
дистрибьютор m
дропшиппинг m
мерчандайзер m
эквайринг m
промокод m
кешбэк m
кэшбэк m
лендинг m
лид m
тренд m
бренд m
брендбук m
ребрендинг m
таргетинг m
геотаргетинг m
ретаргетинг m
таргетолог m
маркетолог m
копирайтер m
рерайтер m
контент m
блогер m
инфлюенсер m
хештег m
хэштег m
репост m
лайк m
подкаст m
стриминг m
вебинар m
чат m
бот m
чат-бот m
стартап m
франчайзинг m
аутсорсинг m
аутстаффинг m
коворкинг m
дедлайн m
фидбэк m
фидбек m
кейс m
скриншот m
скринкаст m
баннер m
виджет m
дашборд m
сервис m
сайт m
скан m
токен m
аккаунт m
логин m
пароль m
смартфон m
гаджет m
планшет m
ноутбук m
апгрейд m
апдейт m
бэкап m
бэкенд m
фронтенд m
датасет m
промпт m
интерфейс m
хостинг m
домен m
сниппет m
трафик m
юзер m
стикер m
чекбокс m
инпут m
плейсхолдер m
тултип m
гипсокартон m
пеноплекс m
праймер m
колер m
колорант m
Яндекс m
Битрикс m
Вебвизор m
Дзен m
инфографика f
аналитика f
оплата f
предоплата f
постоплата f
доплата f
переплата f
недоплата f
маркировка = маркировка маркировки маркировке маркировку маркировкой маркировкою маркировок маркировкам маркировками маркировках
отгрузка = отгрузка отгрузки отгрузке отгрузку отгрузкой отгрузкою отгрузок отгрузкам отгрузками отгрузках
колеровка = колеровка колеровки колеровке колеровку колеровкой колеровкою колеровок колеровкам колеровками колеровках
рассылка = рассылка рассылки рассылке рассылку рассылкой рассылкою рассылок рассылкам рассылками рассылках
выгрузка = выгрузка выгрузки выгрузке выгрузку выгрузкой выгрузкою выгрузок выгрузкам выгрузками выгрузках
интеграция fia
пагинация fia
госрегистрация fia
геолокация fia
монетизация fia
кастомизация fia
персонализация fia
сегментация fia
верификация fia
валидация fia
авторизация fia
аутентификация fia
оптимизация fia
визуализация fia
соцсеть fs
согласие nie
домовладение nie
рождение nie
паллетирование nie
неисполнение nie
непредоставление nie
бонусный adj
маркетинговый adj
мотивационный adj
гипоаллергенный adj
тиксотропный adj
сополимерный adj
покупочный adj
таргетированный adj
брендированный adj
кастомный adj
онлайновый adj
офлайновый adj
колеруемый adj
колерованный adj
заколерованный adj
газоиспользующий adj
колеровать ovat
заколеровать ovat
администрировать ovat
модерировать ovat
отмодерировать ovat
таргетировать ovat
монетизировать ovat
брендировать ovat
кастомизировать ovat
детектировать ovat
верифицировать ovat
валидировать ovat
генерировать ovat
сгенерировать ovat
мониторить = мониторить мониторю мониторишь мониторит мониторим мониторите мониторят мониторил мониторила мониторили мониторя мониторинг
гуглить = гуглить гуглю гуглишь гуглит гуглим гуглите гуглят гуглил гуглила гуглили
скачивать = скачивать скачиваю скачиваешь скачивает скачиваем скачиваете скачивают скачивал скачивала скачивали скачивается скачиваются
оценивание nie
медучреждение nie
юрлицо no
учётка = учётка учётки учётке учётку учёткой учёток учёткам учётками учётках учетка учетки учетке учетку учеткой учеток учеткам учетками учетках
перелинковка = перелинковка перелинковки перелинковке перелинковку перелинковкой перелинковок
поклейка = поклейка поклейки поклейке поклейку поклейкой поклеек
детекция fia
досудебный adj
категорийный adj
оркестратор m
оффер m
трекер m
бриф m
гайд m
монтажёр m
тенант m
мерчант m
амбассадор m
Роскомнадзор m
Директ m
Маркет m
вовлечённость fs
вовлеченность fs
укрывистость fs
# Words that don't decline
онлайн
офлайн
оффлайн
медиа
демо
франчайзи
киноа
смс
пн
вт
ср
чт
пт
сб
вс
-144
View File
@@ -17,13 +17,6 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.divyanshu2074]
real_name = "Divyanshu Gupta"
sync_github_name = true
[people.lokinmodar]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@@ -44,140 +37,3 @@ sync_github_name = true
real_name = "Dmitrii Khlapov"
display_name = "Dima Khlapov"
sync_github_name = false
[people.DanielKnourek]
real_name = "Daniel Kňourek"
display_name = "Daniel Kňourek"
sync_github_name = false
[people.arcanii]
real_name = "Bryan Mark"
display_name = "Arcanii"
sync_github_name = false
[people.henno]
sync_github_name = true
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
display_name = "Ufuk Efe"
sync_github_name = true
[people.GhostMitra]
sync_github_name = true
[people.DevCoderMax]
real_name = "Gleyson do Nascimento Carvalho"
display_name = "Gleyson Carvalho"
sync_github_name = false
[people.alexanderalber]
real_name = "Alexander Alber"
[people.edurbs]
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.manucarcor]
real_name = "Manuel Carmona"
display_name = "manucarcor"
[people.odedigo]
[people.neupaneprashant]
real_name = "Prashant Neupane"
[people.cyril265]
display_name = "cyril265"
[people.webdevtodayjason]
display_name = "Jason Brashear"
sync_github_name = false
[people.lupant]
sync_github_name = true
[people.Nael-Nathanael]
[people.Losminek]
[people.Nghiathan13]
[people.leoncral]
sync_github_name = true
[people.ms-youssef]
sync_github_name = true
[people.finwarman]
real_name = "Fin Warman"
[people.albayazid]
real_name = "Sazid Al Bayazid"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
[people.tlomts-dev]
display_name = "The Legend Of MTS Dev"
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.rbr48]
real_name = "M S Rana"
display_name = "Izhaan Intellect"
[people.holmbom]
real_name = "Erik Holmbom"
display_name = "Erik Holmbom"
sync_github_name = false
[people.andrejskvorc]
real_name = "Andrej Škvorc"
display_name = "Andrej Škvorc"
sync_github_name = false
[people.olekkut]
real_name = "Aleksander Kutycki"
display_name = "Aleksander Kutycki"
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.rem5411]
real_name = "Vivien Janny"
sync_github_name = true
[people.C00kieMonsta]
real_name = "Antoine Boxho"
sync_github_name = true
[people.matteo-andreuzza]
sync_github_name = true
[people.lucAmbr0]
sync_github_name = true
[people.maxm11]
real_name = "Max Murphy"
[people.SlavenDj]
sync_github_name = true
[people.asystemoffields]
display_name = "asystemoffields"
[people.dmim2502-commits]
display_name = "Mulai"
[people.Bortlesboat]
real_name = "Andrew Barnes"
[people.matstrange]
sync_github_name = true
[people.pakiat]
display_name = "OtorO"
-1
View File
@@ -1 +0,0 @@
9205108cfdced5177706264038848b68b2422d2b
-4
View File
@@ -6,11 +6,7 @@ use std::path::{Path, PathBuf};
/// The app's settings folder (preferences, logs, key files): macOS
/// `~/Library/Application Support/WordCraft`, Windows `%APPDATA%\WordCraft`, otherwise
/// `$XDG_CONFIG_HOME/wordcraft` or `~/.config/wordcraft`.
/// `WORDCRAFT_PROFILE_DIR` overrides the entire folder for isolated runs.
pub fn settings_dir() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("WORDCRAFT_PROFILE_DIR").filter(|p| !p.is_empty()) {
return Some(PathBuf::from(path));
}
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/WordCraft"))
} else if cfg!(windows) {
-7
View File
@@ -8,15 +8,8 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
[lints]
workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
# dev-dependency, so never built into the app.
test-support = []
[dependencies]
wordcraft-geom = { workspace = true }
base64 = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
-26
View File
@@ -37,26 +37,6 @@ pub fn has_rtl(text: &str) -> bool {
text.chars().any(is_rtl_script)
}
/// Does the first strong directional character in `text` belong to a right-to-left script?
///
/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R
/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek,
/// Cyrillic, CJK…) give `false`. Neutral, weak and whitespace characters are skipped.
pub fn first_strong_is_rtl(text: &str) -> bool {
for c in text.chars() {
if c == '\u{200F}' {
return true;
}
if c == '\u{200E}' {
return false;
}
if c.is_alphabetic() {
return is_rtl_script(c);
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
@@ -76,11 +56,5 @@ mod tests {
assert!("می‌خواهم".chars().all(is_word_char), "ZWNJ is inside the word");
assert!("کِتاب".chars().all(is_word_char), "so are harakat");
assert!(!is_word_char(' ') && !is_word_char('،'));
assert!(first_strong_is_rtl("سلام"));
assert!(first_strong_is_rtl("من ناوم هێڤاره"));
assert!(first_strong_is_rtl(" 123. سلام"));
assert!(!first_strong_is_rtl("Hello سلام"));
assert!(!first_strong_is_rtl("123 Hello"));
assert!(!first_strong_is_rtl(""));
}
}
-130
View File
@@ -1,130 +0,0 @@
//! How [`crate::Document::media`] and [`crate::Document::passthrough`] are written in the native
//! JSON form (`.json` files and crash-recovery copies): each part's bytes as one base64 string.
//! serde's default for bytes, an array of numbers, took about 3.6 times their size compact and
//! over six times pretty-printed (a 27 MB picture made a 99 MB recovery copy, #586).
//!
//! Reading takes either form, so `.json` files and recovery copies written before still open.
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
use base64::Engine as _;
use base64::display::Base64Display;
use base64::engine::general_purpose::STANDARD;
use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
use serde::ser::{Serialize, Serializer};
type Store = BTreeMap<String, Arc<Vec<u8>>>;
/// One part's bytes, encoded as it is written out: no second copy of a large picture is made.
struct Encoded<'a>(&'a [u8]);
impl Serialize for Encoded<'_> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(&Base64Display::new(self.0, &STANDARD))
}
}
pub(crate) fn serialize<S: Serializer>(store: &Store, s: S) -> Result<S::Ok, S::Error> {
s.collect_map(store.iter().map(|(name, bytes)| (name, Encoded(bytes))))
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Store, D::Error> {
d.deserialize_map(StoreVisitor)
}
struct StoreVisitor;
impl<'de> Visitor<'de> for StoreVisitor {
type Value = Store;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a map of part names to base64 text or byte arrays")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Store, A::Error> {
let mut store = Store::new();
while let Some((name, Bytes(bytes))) = map.next_entry::<String, Bytes>()? {
store.insert(name, Arc::new(bytes));
}
Ok(store)
}
}
/// A part's bytes as either form.
struct Bytes(Vec<u8>);
impl<'de> de::Deserialize<'de> for Bytes {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_any(BytesVisitor).map(Bytes)
}
}
struct BytesVisitor;
impl<'de> Visitor<'de> for BytesVisitor {
type Value = Vec<u8>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("base64 text or an array of bytes")
}
fn visit_str<E: de::Error>(self, text: &str) -> Result<Vec<u8>, E> {
STANDARD.decode(text).map_err(|e| E::custom(format!("bytes are not valid base64: {e}")))
}
fn visit_bytes<E: de::Error>(self, bytes: &[u8]) -> Result<Vec<u8>, E> {
Ok(bytes.to_vec())
}
/// The form written before: one number per byte.
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u8>, A::Error> {
// The length hint comes from the file: trust it only so far.
let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(1 << 20));
while let Some(b) = seq.next_element::<u8>()? {
out.push(b);
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Default)]
struct Parts {
#[serde(default, with = "super")]
media: Store,
}
fn parts(entries: &[(&str, &[u8])]) -> Parts {
Parts { media: entries.iter().map(|(n, b)| ((*n).to_string(), Arc::new(b.to_vec()))).collect() }
}
#[test]
fn bytes_are_written_as_base64_and_read_back() {
let all: Vec<u8> = (0..=255).collect();
for n in [0, 1, 2, 3, 4, 255, 256] {
let p = parts(&[("a.png", &all[..n.min(all.len())])]);
let text = serde_json::to_string(&p).unwrap();
assert_eq!(serde_json::from_str::<Parts>(&text).unwrap(), p, "{n}");
}
assert_eq!(serde_json::to_string(&parts(&[("m", b"Man"), ("ma", b"Ma")])).unwrap(), r#"{"media":{"m":"TWFu","ma":"TWE="}}"#);
}
#[test]
fn number_arrays_written_before_still_read() {
let old = r#"{"media":{"a.png":[0,1,127,128,254,255],"empty":[]}}"#;
assert_eq!(serde_json::from_str::<Parts>(old).unwrap(), parts(&[("a.png", &[0, 1, 127, 128, 254, 255]), ("empty", &[])]));
}
#[test]
fn hostile_values_are_errors_not_panics() {
for bad in [r#""T""#, r#""TWFu!""#, r#""-_""#, r#""TQ=x""#, "[256]", "[-1]", "[1.5]", "true", "{}", r#"["a"]"#] {
let text = format!(r#"{{"media":{{"a.png":{bad}}}}}"#);
assert!(serde_json::from_str::<Parts>(&text).is_err(), "{bad}");
}
assert!(serde_json::from_str::<Parts>(r#"{"media":"x"}"#).is_err());
}
}
-133
View File
@@ -1,133 +0,0 @@
//! A bytes-only check of an OLE compound file ([MS-CFB]) before the `cfb` crate opens it
//! (#3642). Every reader of `.doc`, `.xls` and encrypted Office packages calls [`check`] first.
/// Check a compound file's FAT layout before `cfb` reads it ([MS-CFB] §2.2 header, §2.5
/// DIFAT). `cfb` appends a whole sector of FAT entries for every FAT sector the DIFAT lists and
/// only validates the FAT once it is built, so a small header naming one sector over and over
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
/// `cfb` to report.
pub fn check(bytes: &[u8]) -> Result<(), String> {
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
const FREE: u32 = 0xFFFF_FFFF;
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
const HEADER_ENTRIES: usize = 109;
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]));
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
if bytes.len() < 512 || bytes.get(..8) != Some(MAGIC.as_slice()) {
return Ok(());
}
let sector_len: usize = match u16_at(0x1A) {
Some(3) => 512,
Some(4) => 4096,
_ => return Ok(()),
};
if bytes.len() < sector_len {
return Ok(());
}
// `cfb` counts the sectors after the header sector, the last one possibly partial.
let num_sectors = bytes.len().div_ceil(sector_len) - 1;
let num_fat_sectors = u32_at(0x2C).unwrap_or(0) as usize;
let mut difat: Vec<u32> = (0..HEADER_ENTRIES).filter_map(|i| u32_at(0x4C + i * 4)).collect();
let mut next = u32_at(0x44).unwrap_or(END_OF_CHAIN);
let mut steps = 0usize;
while next != END_OF_CHAIN && next != FREE {
steps += 1;
// `cfb` refuses a chain sector past the file or seen before; a chain longer than the
// file has sectors must have done one of the two.
if steps > num_sectors || next as usize >= num_sectors {
return Err(format!("the DIFAT chain is longer than the file or leaves it (sector {next})"));
}
let at = (next as usize + 1) * sector_len;
let entries = sector_len / 4 - 1;
difat.extend((0..entries).map(|i| u32_at(at + i * 4).unwrap_or(FREE)));
next = u32_at(at + entries * 4).unwrap_or(END_OF_CHAIN);
}
while difat.len() > HEADER_ENTRIES && difat.len() > num_fat_sectors && difat.last() == Some(&0) {
difat.pop();
}
while difat.last() == Some(&FREE) {
difat.pop();
}
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
if listed.len() > needed {
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
}
let mut seen = std::collections::HashSet::with_capacity(listed.len());
if let Some(dup) = listed.iter().find(|&&s| !seen.insert(s)) {
return Err(format!("the DIFAT lists FAT sector {dup} twice"));
}
Ok(())
}
/// A compound file of about 1 MB whose DIFAT chain lists one FAT sector 2048 × 127 times:
/// without [`check`], `cfb` builds a FAT of about 130 MB for it. Regression input for the
/// readers' tests: built only for tests, this crate's and (through the `test-support` feature,
/// which they turn on as a dev-dependency) the readers', never into the app.
#[cfg(any(test, feature = "test-support"))]
pub fn repeating_difat_sample() -> Vec<u8> {
const S: usize = 512;
const CHAIN: u32 = 2048; // DIFAT sectors 1..=CHAIN, sector 0 is the "FAT"
let mut f = vec![0u8; S * (CHAIN as usize + 2)];
let mut put = |at: usize, v: &[u8]| {
if let Some(dst) = f.get_mut(at..at + v.len()) {
dst.copy_from_slice(v);
}
};
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
put(0x18, &0x3Eu16.to_le_bytes());
put(0x1A, &3u16.to_le_bytes());
put(0x1C, &0xFFFEu16.to_le_bytes());
put(0x1E, &9u16.to_le_bytes());
put(0x20, &6u16.to_le_bytes());
put(0x2C, &u32::MAX.to_le_bytes()); // no zero trimming
put(0x38, &4096u32.to_le_bytes());
put(0x44, &1u32.to_le_bytes());
put(0x48, &CHAIN.to_le_bytes());
// The 109 header entries are all 0 already; each chain sector's entries too.
for d in 1..=CHAIN {
let next = if d == CHAIN { 0xFFFF_FFFE } else { d + 1 };
put((d as usize + 2) * S - 4, &next.to_le_bytes());
}
f
}
#[cfg(test)]
mod tests {
#[test]
fn a_repeating_difat_is_refused_and_a_small_file_passes() {
assert!(super::check(&super::repeating_difat_sample()).is_err_and(|e| e.contains("FAT sectors in a file of")));
assert!(super::check(b"not a compound file").is_ok());
}
/// A 512-byte-sector file of `sectors` sectors after the header, whose header DIFAT lists
/// `fat` (the rest free).
fn header_difat(sectors: usize, fat: &[u32]) -> Vec<u8> {
let mut f = vec![0u8; 512 * (sectors + 1)];
f[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
f[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes());
f[0x2C..0x30].copy_from_slice(&(fat.len() as u32).to_le_bytes());
f[0x44..0x48].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes());
for i in 0..109 {
let v = fat.get(i).copied().unwrap_or(u32::MAX);
f[0x4C + i * 4..0x50 + i * 4].copy_from_slice(&v.to_le_bytes());
}
f
}
#[test]
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
// Each list is shorter than the file has sectors, which the guard used to accept.
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
assert!(super::check(&header_difat(10, &[0])).is_ok());
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
}
}
-261
View File
@@ -1,261 +0,0 @@
//! Charts WordCraft makes and edits (Insert › Chart): the chart type, its data (categories and
//! series), title, legend and data labels.
//!
//! A chart object ([`crate::graphic::Graphic`] with `chart` set) keeps this model next to the
//! items it is drawn with; the DOCX writer turns it into a chart part (`c:chartSpace`) with the
//! data cached in the part itself, and the reader turns a chart part back into it when the part
//! holds nothing this model leaves out. Charts from other programs stay as they were read.
//!
//! Every value from outside (commands, files) goes through [`ChartSpec::sanitize`]: series,
//! points and text lengths are capped and values that aren't finite numbers become gaps.
use serde::{Deserialize, Serialize};
/// Most series in a chart.
pub const MAX_SERIES: usize = 255;
/// Most categories (points per series) in a chart.
pub const MAX_POINTS: usize = 4000;
/// Most cells (series × categories) in a chart: what the chart drawing reads at most.
pub const MAX_CELLS: usize = 100_000;
/// Longest title, series name or category label, characters.
pub const MAX_TEXT: usize = 255;
/// What a chart shows its data as.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChartType {
/// Clustered columns.
#[default]
Column,
/// Stacked columns.
ColumnStacked,
/// Clustered horizontal bars.
Bar,
/// Stacked horizontal bars.
BarStacked,
Line,
Area,
Pie,
Doughnut,
/// XY scatter with markers: the categories are the x values.
Scatter,
}
impl ChartType {
pub const ALL: [ChartType; 9] = [
ChartType::Column,
ChartType::ColumnStacked,
ChartType::Bar,
ChartType::BarStacked,
ChartType::Line,
ChartType::Area,
ChartType::Pie,
ChartType::Doughnut,
ChartType::Scatter,
];
/// The id commands use (`column`, `barStacked`…).
pub fn id(self) -> &'static str {
match self {
ChartType::Column => "column",
ChartType::ColumnStacked => "columnStacked",
ChartType::Bar => "bar",
ChartType::BarStacked => "barStacked",
ChartType::Line => "line",
ChartType::Area => "area",
ChartType::Pie => "pie",
ChartType::Doughnut => "doughnut",
ChartType::Scatter => "scatter",
}
}
/// The type an id names (any ASCII case).
pub fn from_id(id: &str) -> Option<ChartType> {
ChartType::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(id.trim()))
}
/// The name shown for it.
pub fn label(self) -> &'static str {
match self {
ChartType::Column => "Clustered Column",
ChartType::ColumnStacked => "Stacked Column",
ChartType::Bar => "Clustered Bar",
ChartType::BarStacked => "Stacked Bar",
ChartType::Line => "Line",
ChartType::Area => "Area",
ChartType::Pie => "Pie",
ChartType::Doughnut => "Doughnut",
ChartType::Scatter => "Scatter",
}
}
/// Pie and doughnut charts show one series, a slice per category.
pub fn is_round(self) -> bool {
matches!(self, ChartType::Pie | ChartType::Doughnut)
}
}
/// Where a chart's legend sits.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LegendPos {
Right,
Left,
Top,
#[default]
Bottom,
}
impl LegendPos {
pub const ALL: [LegendPos; 4] = [LegendPos::Right, LegendPos::Top, LegendPos::Left, LegendPos::Bottom];
pub fn id(self) -> &'static str {
match self {
LegendPos::Right => "right",
LegendPos::Left => "left",
LegendPos::Top => "top",
LegendPos::Bottom => "bottom",
}
}
pub fn from_id(id: &str) -> Option<LegendPos> {
LegendPos::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id.trim()))
}
}
/// One series: its name and a value per category (`None` = no value, a gap).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartSeries {
pub name: String,
#[serde(default)]
pub values: Vec<Option<f64>>,
}
/// A chart WordCraft can edit.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartSpec {
#[serde(rename = "type")]
pub kind: ChartType,
/// Category labels (scatter charts: the x values).
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub series: Vec<ChartSeries>,
#[serde(default)]
pub title: Option<String>,
/// `None`: no legend.
#[serde(default)]
pub legend: Option<LegendPos>,
/// Show each point's value next to it.
#[serde(default)]
pub data_labels: bool,
}
impl ChartSpec {
/// A new chart of type `kind` with WordCraft's sample data: four quarters and three series
/// (one for pie and doughnut charts, x values for scatter charts), legend at the bottom.
pub fn sample(kind: ChartType) -> ChartSpec {
let (categories, series): (Vec<&str>, Vec<(&str, [f64; 4])>) = match kind {
ChartType::Pie | ChartType::Doughnut => (vec!["North", "East", "South", "West"], vec![("Share", [38.0, 24.0, 21.0, 17.0])]),
ChartType::Scatter => (vec!["1", "2", "3", "4"], vec![("Series 1", [2.1, 3.4, 3.0, 4.6]), ("Series 2", [1.2, 1.9, 2.8, 3.1])]),
_ => (
vec!["Q1", "Q2", "Q3", "Q4"],
vec![("Series 1", [3.2, 4.1, 3.6, 5.0]), ("Series 2", [2.0, 2.7, 3.9, 3.3]), ("Series 3", [1.5, 2.2, 2.4, 3.8])],
),
};
ChartSpec {
kind,
categories: categories.into_iter().map(str::to_string).collect(),
series: series.into_iter().map(|(n, v)| ChartSeries { name: n.to_string(), values: v.into_iter().map(Some).collect() }).collect(),
title: None,
legend: Some(LegendPos::Bottom),
data_labels: false,
}
}
/// Number of categories: every series has as many values.
pub fn points(&self) -> usize {
self.categories.len()
}
/// Make the chart safe to store and draw: text cut to [`MAX_TEXT`] characters with control
/// characters turned into spaces and the ends trimmed (an empty title is none); at most
/// [`MAX_POINTS`] categories, [`MAX_SERIES`] series and [`MAX_CELLS`] cells; values that
/// aren't finite become gaps; every series as long as the categories (padded with gaps,
/// categories padded with empty labels to the longest series).
pub fn sanitize(&mut self) {
self.series.truncate(MAX_SERIES);
let longest = self.series.iter().map(|s| s.values.len()).max().unwrap_or(0);
let n = self.categories.len().max(longest).min(MAX_POINTS);
let keep = MAX_CELLS.checked_div(n).unwrap_or(MAX_SERIES).clamp(1, MAX_SERIES);
self.series.truncate(keep);
self.categories.truncate(n);
for c in &mut self.categories {
*c = clean_text(c);
}
self.categories.resize(n, String::new());
for s in &mut self.series {
s.name = clean_text(&s.name);
s.values.truncate(n);
for v in &mut s.values {
*v = v.filter(|x| x.is_finite());
}
s.values.resize(n, None);
}
self.title = self.title.as_deref().map(clean_text).filter(|t| !t.is_empty());
}
/// The chart after `sanitize`.
pub fn sanitized(mut self) -> ChartSpec {
self.sanitize();
self
}
}
/// `s` as chart text: control characters as spaces, ends trimmed, at most [`MAX_TEXT`] characters.
pub fn clean_text(s: &str) -> String {
let s: String = s.chars().take(MAX_TEXT * 4).map(|c| if c.is_control() { ' ' } else { c }).collect();
s.trim().chars().take(MAX_TEXT).collect::<String>().trim_end().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_caps_sizes_and_drops_non_finite_values() {
let mut c = ChartSpec {
categories: vec!["a".into(); MAX_POINTS + 10],
series: (0..MAX_SERIES + 5)
.map(|i| ChartSeries { name: format!("{}\n{}", "x".repeat(400), i), values: vec![Some(f64::NAN), Some(f64::INFINITY), Some(1.0)] })
.collect(),
title: Some(" ".into()),
..Default::default()
};
c.sanitize();
assert_eq!(c.points(), MAX_POINTS);
assert!(c.series.len() * c.points() <= MAX_CELLS);
assert!(!c.series.is_empty());
let s = &c.series[0];
assert_eq!(s.values.len(), MAX_POINTS);
assert_eq!(&s.values[..3], &[None, None, Some(1.0)]);
assert_eq!(s.name.chars().count(), MAX_TEXT);
assert!(!s.name.contains('\n'));
assert_eq!(c.title, None);
}
#[test]
fn short_categories_are_padded_to_the_longest_series() {
let c = ChartSpec {
categories: vec!["a".into()],
series: vec![ChartSeries { name: "s".into(), values: vec![Some(1.0), Some(2.0)] }],
..Default::default()
}
.sanitized();
assert_eq!(c.categories, vec!["a".to_string(), String::new()]);
assert_eq!(ChartType::from_id("BARSTACKED"), Some(ChartType::BarStacked));
assert_eq!(LegendPos::from_id("top"), Some(LegendPos::Top));
}
}
-386
View File
@@ -1,386 +0,0 @@
//! East Asian list and page number formats (ECMA-376 §17.18.59 ST_NumberFormat): Chinese,
//! Japanese and Korean counting systems (with units 十/百/千 and myriads 万/億), legal (financial)
//! numerals, digit-by-digit ideographs, the heavenly stems and earthly branches, kana and hangul
//! sequences and enclosed numbers. Written from the number systems' rules; a value outside a
//! sequence's range falls back to plain decimal.
/// Whether a counting system writes the "one" before a unit.
#[derive(Clone, Copy, PartialEq, Eq)]
enum OmitOne {
/// Always written: legal numerals (壹拾, 壱百).
Never,
/// Dropped only before a leading ten: 十, 十一, 十万 but 一百一十 (Chinese).
LeadingTen,
/// Dropped before 十, 百 and 千 inside every group of four: 十, 百, 千 but 一万 (Japanese,
/// Korean).
Units,
}
/// A myriad-based counting system (groups of four digits, like 1|2345|6789).
struct Counting {
/// The digits 1 to 9 (index 0 unused).
digits: [char; 10],
/// The units ten, hundred, thousand.
units: [char; 3],
/// The myriads: ten thousand, hundred million.
myriads: [char; 2],
/// The character written once for a run of zeros between two digits (Chinese 零); `None`
/// skips zeros (Japanese, Korean).
zero: Option<char>,
omit_one: OmitOne,
}
impl Counting {
fn format(&self, n: u32) -> String {
if n == 0 {
return self.zero.unwrap_or('〇').to_string();
}
let groups = [n / 100_000_000, (n / 10_000) % 10_000, n % 10_000];
let mut out = String::new();
let mut zero_pending = false;
for (gi, &g) in groups.iter().enumerate() {
if g == 0 {
zero_pending |= !out.is_empty();
continue;
}
let ds = [g / 1000, (g / 100) % 10, (g / 10) % 10, g % 10];
for (pos, &d) in ds.iter().enumerate() {
if d == 0 {
zero_pending |= !out.is_empty();
continue;
}
if zero_pending && let Some(z) = self.zero {
out.push(z);
}
zero_pending = false;
// pos 0 = thousands, 1 = hundreds, 2 = tens, 3 = ones.
let unit = match pos {
0 => self.units.get(2),
1 => self.units.get(1),
2 => self.units.first(),
_ => None,
};
let omit = d == 1
&& unit.is_some()
&& match self.omit_one {
OmitOne::Never => false,
OmitOne::LeadingTen => pos == 2 && out.is_empty(),
OmitOne::Units => true,
};
if !omit {
out.push(self.digits.get(d as usize).copied().unwrap_or('?'));
}
if let Some(&u) = unit {
out.push(u);
}
}
// Trailing zeros of a group are not a gap: the next group starts a new place.
zero_pending = false;
match gi {
0 => out.extend(self.myriads.get(1)),
1 => out.extend(self.myriads.first()),
_ => {}
}
}
out
}
}
const CN_DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
const CHINESE: Counting =
Counting { digits: CN_DIGITS, units: ['十', '百', '千'], myriads: ['万', '亿'], zero: Some('零'), omit_one: OmitOne::LeadingTen };
const TAIWANESE: Counting =
Counting { digits: CN_DIGITS, units: ['十', '百', '千'], myriads: ['萬', '億'], zero: Some('零'), omit_one: OmitOne::LeadingTen };
const CHINESE_LEGAL: Counting = Counting {
digits: ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'],
units: ['拾', '佰', '仟'],
myriads: ['万', '亿'],
zero: Some('零'),
omit_one: OmitOne::Never,
};
const TRADITIONAL_LEGAL: Counting = Counting {
digits: ['零', '壹', '貳', '參', '肆', '伍', '陸', '柒', '捌', '玖'],
units: ['拾', '佰', '仟'],
myriads: ['萬', '億'],
zero: Some('零'),
omit_one: OmitOne::Never,
};
const JAPANESE: Counting =
Counting { digits: CN_DIGITS, units: ['十', '百', '千'], myriads: ['万', '億'], zero: None, omit_one: OmitOne::Units };
const JAPANESE_LEGAL: Counting = Counting {
digits: ['〇', '壱', '弐', '参', '四', '伍', '六', '七', '八', '九'],
units: ['拾', '百', '阡'],
myriads: ['萬', '億'],
zero: None,
omit_one: OmitOne::Never,
};
const KOREAN: Counting = Counting {
digits: ['영', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'],
units: ['십', '백', '천'],
myriads: ['만', '억'],
zero: None,
omit_one: OmitOne::Units,
};
/// 一, 二 … 十, 十一 … 九十九 (Chinese counting); from 100 on, digit by digit (一〇〇).
pub(crate) fn chinese_counting(n: u32) -> String {
if n < 100 { CHINESE.format(n) } else { digits(n, &CN_DIGITS) }
}
/// Chinese counting with every unit: 一百零一, 一千, 一万.
pub(crate) fn chinese_counting_thousand(n: u32) -> String {
CHINESE.format(n)
}
/// Simplified Chinese legal (financial) numerals: 壹, 贰, 叁 … 壹拾.
pub(crate) fn chinese_legal_simplified(n: u32) -> String {
CHINESE_LEGAL.format(n)
}
/// Traditional Chinese legal numerals: 壹, 貳, 參 … 壹拾.
pub(crate) fn ideograph_legal_traditional(n: u32) -> String {
TRADITIONAL_LEGAL.format(n)
}
/// Taiwanese counting: like Chinese counting below 100, digit by digit above.
pub(crate) fn taiwanese_counting(n: u32) -> String {
if n < 100 { TAIWANESE.format(n) } else { digits(n, &CN_DIGITS) }
}
/// Taiwanese counting with every unit: 一百零一, 一千, 一萬.
pub(crate) fn taiwanese_counting_thousand(n: u32) -> String {
TAIWANESE.format(n)
}
/// Japanese counting: 一 … 十, 十一, 百, 千, 一万.
pub(crate) fn japanese_counting(n: u32) -> String {
JAPANESE.format(n)
}
/// Japanese legal numerals: 壱, 弐, 参 … 壱拾.
pub(crate) fn japanese_legal(n: u32) -> String {
JAPANESE_LEGAL.format(n)
}
/// Japanese digits with 万/億 marking each group of four: 一二, 一万二三四五.
pub(crate) fn japanese_digital_ten_thousand(n: u32) -> String {
let groups = [(n / 100_000_000, Some('億')), ((n / 10_000) % 10_000, Some('万')), (n % 10_000, None)];
let mut out = String::new();
for (g, mark) in groups {
let s = digits(g, &CN_DIGITS);
if out.is_empty() {
if g == 0 && mark.is_some() {
continue;
}
} else {
// Inner groups keep their four places: 一万〇〇〇一.
out.extend(std::iter::repeat_n('〇', 4usize.saturating_sub(s.chars().count())));
}
out.push_str(&s);
out.extend(mark);
}
out
}
/// Sino-Korean numbers in hangul: 일, 이, 삼 … 십, 십일, 백.
pub(crate) fn korean_counting(n: u32) -> String {
KOREAN.format(n)
}
/// Sino-Korean digits in hangul, one per digit: 일, 일영, 일일.
pub(crate) fn korean_digital(n: u32) -> String {
digits(n, &KOREAN.digits)
}
const KO_ONES: [&str; 10] = ["", "하나", "둘", "셋", "넷", "다섯", "여섯", "일곱", "여덟", "아홉"];
const KO_TENS: [&str; 10] = ["", "열", "스물", "서른", "마흔", "쉰", "예순", "일흔", "여든", "아흔"];
/// Native Korean numbers: 하나, 둘, 셋 … 열, 열하나 … 아흔아홉 (decimal from 100 on).
pub(crate) fn korean_legal(n: u32) -> String {
if n == 0 || n > 99 {
return n.to_string();
}
let (t, o) = ((n / 10) as usize, (n % 10) as usize);
format!("{}{}", KO_TENS.get(t).copied().unwrap_or(""), KO_ONES.get(o).copied().unwrap_or(""))
}
/// One ideograph per decimal digit: 一, 一〇, 一〇一.
pub(crate) fn ideograph_digital(n: u32) -> String {
digits(n, &CN_DIGITS)
}
fn digits(n: u32, set: &[char; 10]) -> String {
n.to_string().bytes().map(|b| set.get(b.saturating_sub(b'0') as usize).copied().unwrap_or('?')).collect()
}
const STEMS: [char; 10] = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
const BRANCHES: [char; 12] = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
/// The ten heavenly stems: 甲, 乙, 丙 … 癸.
pub(crate) fn ideograph_traditional(n: u32) -> String {
nth(&STEMS, n)
}
/// The twelve earthly branches: 子, 丑, 寅 … 亥.
pub(crate) fn ideograph_zodiac(n: u32) -> String {
nth(&BRANCHES, n)
}
/// The sixty-year cycle: 甲子, 乙丑, 丙寅 … 癸亥.
pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String {
if !(1..=60).contains(&n) {
return n.to_string();
}
let i = (n - 1) as usize;
[STEMS.get(i % 10), BRANCHES.get(i % 12)].into_iter().flatten().collect()
}
/// The `n`th (1-based) item of a sequence, or decimal past its end.
fn nth(set: &[char], n: u32) -> String {
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(ToString::to_string).unwrap_or_else(|| n.to_string())
}
/// Code points `first..` enclosing 1 to `count`, decimal beyond.
fn enclosed(n: u32, first: u32, count: u32) -> String {
if (1..=count).contains(&n)
&& let Some(c) = char::from_u32(first + n - 1)
{
return c.to_string();
}
n.to_string()
}
/// ① ② ③ … ⑳.
pub(crate) fn decimal_enclosed_circle(n: u32) -> String {
enclosed(n, 0x2460, 20)
}
/// ① … ⑩ (the Chinese set stops at ten).
pub(crate) fn decimal_enclosed_circle_chinese(n: u32) -> String {
enclosed(n, 0x2460, 10)
}
/// ⒈ ⒉ ⒊ … ⒛.
pub(crate) fn decimal_enclosed_fullstop(n: u32) -> String {
enclosed(n, 0x2488, 20)
}
/// ⑴ ⑵ ⑶ … ⒇.
pub(crate) fn decimal_enclosed_paren(n: u32) -> String {
enclosed(n, 0x2474, 20)
}
/// ㊀ ㊁ ㊂ … ㊉ (circled ideographs).
pub(crate) fn ideograph_enclosed_circle(n: u32) -> String {
enclosed(n, 0x3280, 10)
}
/// Full-width digits: 1, 2 … 10.
pub(crate) fn decimal_full_width(n: u32) -> String {
n.to_string().chars().map(|c| c.to_digit(10).and_then(|d| char::from_u32(0xFF10 + d)).unwrap_or(c)).collect()
}
const AIUEO: &str = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン";
const AIUEO_HALF: &str = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン";
const IROHA: &str = "イロハニホヘトチリヌルヲワカヨタレソツネナラムウヰノオクヤマケフコエテアサキユメミシヱヒモセス";
const IROHA_HALF: &str = "イロハニホヘトチリヌルヲワカヨタレソツネナラムウヰノオクヤマケフコエテアサキユメミシヱヒモセス";
const GANADA: &str = "가나다라마바사아자차카타파하";
const CHOSUNG: &str = "ㄱㄴㄷㄹㅁㅂㅅㅇㅈㅊㅋㅌㅍㅎ";
fn nth_of(set: &str, n: u32) -> String {
n.checked_sub(1).and_then(|i| set.chars().nth(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
}
/// Half-width katakana in gojūon order: ア, イ, ウ …
pub(crate) fn aiueo(n: u32) -> String {
nth_of(AIUEO_HALF, n)
}
/// Full-width katakana in gojūon order: ア, イ, ウ …
pub(crate) fn aiueo_full_width(n: u32) -> String {
nth_of(AIUEO, n)
}
/// Half-width katakana in iroha order: イ, ロ, ハ …
pub(crate) fn iroha(n: u32) -> String {
nth_of(IROHA_HALF, n)
}
/// Full-width katakana in iroha order: イ, ロ, ハ …
pub(crate) fn iroha_full_width(n: u32) -> String {
nth_of(IROHA, n)
}
/// Hangul syllables: 가, 나, 다 …
pub(crate) fn ganada(n: u32) -> String {
nth_of(GANADA, n)
}
/// Hangul initial consonants: ㄱ, ㄴ, ㄷ …
pub(crate) fn chosung(n: u32) -> String {
nth_of(CHOSUNG, n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chinese_no_zero_after_group_trailing_zeros() {
assert_eq!(chinese_counting_thousand(201_000), "二十万一千");
assert_eq!(chinese_counting_thousand(120_000), "十二万");
assert_eq!(chinese_counting_thousand(100_100), "十万零一百");
assert_eq!(taiwanese_counting_thousand(201_000), "二十萬一千");
assert_eq!(chinese_counting_thousand(100_000_001), "一亿零一");
assert_eq!(chinese_counting_thousand(100_010_000), "一亿零一万");
assert_eq!(chinese_counting_thousand(120_000_001), "一亿二千万零一");
}
#[test]
fn chinese_counting_systems() {
let c = |n| chinese_counting(n);
assert_eq!([c(1), c(2), c(3), c(10), c(11), c(20), c(21), c(99)], ["一", "二", "三", "十", "十一", "二十", "二十一", "九十九"]);
assert_eq!([c(100), c(101), c(1000)], ["一〇〇", "一〇一", "一〇〇〇"]);
let t = |n| chinese_counting_thousand(n);
assert_eq!(
[t(10), t(11), t(20), t(101), t(110), t(111), t(1000), t(1010)],
["十", "十一", "二十", "一百零一", "一百一十", "一百一十一", "一千", "一千零一十"]
);
assert_eq!([t(10_000), t(10_001), t(100_100), t(1_000_000), t(100_000_001)], ["一万", "一万零一", "十万零一百", "一百万", "一亿零一"]);
assert_eq!(t(u32::MAX), "四十二亿九千四百九十六万七千二百九十五");
let l = |n| chinese_legal_simplified(n);
assert_eq!([l(1), l(3), l(10), l(11), l(101), l(1000)], ["壹", "叁", "壹拾", "壹拾壹", "壹佰零壹", "壹仟"]);
assert_eq!([taiwanese_counting_thousand(10_001), ideograph_legal_traditional(20)], ["一萬零一", "貳拾"]);
assert_eq!([ideograph_digital(10), ideograph_digital(101)], ["一〇", "一〇一"]);
}
#[test]
fn japanese_and_korean() {
let j = |n| japanese_counting(n);
assert_eq!([j(10), j(11), j(20), j(101), j(1000), j(10_000), j(10_001)], ["十", "十一", "二十", "百一", "千", "一万", "一万一"]);
assert_eq!([japanese_legal(10), japanese_legal(1000)], ["壱拾", "壱阡"]);
let d = |n| japanese_digital_ten_thousand(n);
assert_eq!([d(12), d(10_000), d(12_345), d(100_000_000)], ["一二", "一万〇〇〇〇", "一万二三四五", "一億〇〇〇〇万〇〇〇〇"]);
assert_eq!([korean_counting(11), korean_counting(20), korean_counting(101)], ["십일", "이십", "백일"]);
assert_eq!(
[korean_digital(10), korean_legal(1), korean_legal(11), korean_legal(20), korean_legal(100)],
["일영", "하나", "열하나", "스물", "100"]
);
}
#[test]
fn sequences_and_enclosed_numbers() {
assert_eq!([ideograph_traditional(1), ideograph_traditional(10), ideograph_traditional(11)], ["甲", "癸", "11"]);
assert_eq!([ideograph_zodiac(1), ideograph_zodiac(12), ideograph_zodiac(13)], ["子", "亥", "13"]);
assert_eq!([ideograph_zodiac_traditional(1), ideograph_zodiac_traditional(11), ideograph_zodiac_traditional(60)], ["甲子", "甲戌", "癸亥"]);
assert_eq!([decimal_enclosed_circle(1), decimal_enclosed_circle(20), decimal_enclosed_circle(21)], ["①", "⑳", "21"]);
assert_eq!([decimal_enclosed_circle_chinese(10), decimal_enclosed_circle_chinese(11)], ["⑩", "11"]);
assert_eq!([decimal_enclosed_fullstop(1), decimal_enclosed_paren(20), ideograph_enclosed_circle(1)], ["⒈", "⒇", "㊀"]);
assert_eq!(decimal_full_width(10), "10");
assert_eq!([aiueo_full_width(1), aiueo_full_width(46), aiueo_full_width(47), aiueo(2)], ["ア", "ン", "47", "イ"]);
assert_eq!([iroha_full_width(1), iroha_full_width(47), iroha(3)], ["イ", "ス", "ハ"]);
assert_eq!([ganada(1), chosung(14), ganada(0)], ["가", "ㅎ", "0"]);
assert_eq!([AIUEO.chars().count(), AIUEO_HALF.chars().count(), IROHA.chars().count(), IROHA_HALF.chars().count()], [46, 46, 47, 47]);
}
}
-236
View File
@@ -1,236 +0,0 @@
//! Text clusters for complex scripts (Myanmar, Khmer, Tai Tham, the Indic scripts): where the
//! caret may stop, what Backspace removes, and where Myanmar text may wrap.
//!
//! Extended grapheme clusters (UAX #29) split some syllables that a shaper draws as one unit: a
//! Myanmar consonant stacked under a virama (`မ္ဘ`), the Myanmar vowel signs U+102B/U+102C and
//! the visarga U+1038 (excluded from the grapheme `SpacingMark` class), Khmer subscript
//! consonants after the coeng. A caret between those would sit inside a glyph cluster, so the
//! caret steps and layout clusters both use [`joins_cluster`] on top of the grapheme rules.
use unicode_segmentation::UnicodeSegmentation;
/// Invisible stackers: the next consonant is drawn under this one (Myanmar virama, Khmer coeng,
/// Tai Tham sakot).
fn is_stacker(c: char) -> bool {
matches!(u32::from(c), 0x1039 | 0x17D2 | 0x1A60)
}
/// Dependent vowel signs, medials, tone marks and other combining signs of Myanmar, Khmer and
/// Tai Tham: never the start of a cluster.
fn is_dependent(c: char) -> bool {
matches!(u32::from(c),
0x102B..=0x103E
| 0x1056..=0x1059
| 0x105E..=0x1060
| 0x1062..=0x1064
| 0x1067..=0x106D
| 0x1071..=0x1074
| 0x1082..=0x108D
| 0x108F
| 0x109A..=0x109D
| 0xA9E5
| 0xAA7B..=0xAA7D
| 0x17B4..=0x17D1
| 0x17D3
| 0x17DD
| 0x1A55..=0x1A5E
| 0x1A61..=0x1A7C
| 0x1A7F)
}
/// No caret stop between `prev` and `next` although a grapheme boundary falls there: `next` is a
/// sign that belongs to the syllable before it, or `prev` stacks the next consonant.
pub fn joins_cluster(prev: char, next: char) -> bool {
is_stacker(prev) || is_dependent(next)
}
/// Is `off` a cluster boundary of `text` (0 and `text.len()` always are)?
pub fn is_cluster_boundary(text: &str, off: usize) -> bool {
if off == 0 || off >= text.len() {
return true;
}
if !text.is_char_boundary(off) {
return false;
}
let mut g = unicode_segmentation::GraphemeCursor::new(off, text.len(), true);
if !g.is_boundary(text, 0).unwrap_or(true) {
return false;
}
let prev = text.get(..off).and_then(|t| t.chars().next_back());
let next = text.get(off..).and_then(|t| t.chars().next());
!matches!((prev, next), (Some(p), Some(n)) if joins_cluster(p, n))
}
/// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the
/// first is 0 for non-empty text.
pub fn cluster_starts(text: &str) -> Vec<usize> {
let mut out: Vec<usize> = Vec::new();
let mut prev: Option<char> = None;
for (i, g) in text.grapheme_indices(true) {
let first = g.chars().next();
let joined = matches!((prev, first), (Some(p), Some(n)) if joins_cluster(p, n));
if !joined {
out.push(i);
}
prev = g.chars().next_back();
}
out
}
/// `off` clamped to `text` and moved back to a character boundary.
fn char_floor(text: &str, off: usize) -> usize {
let mut o = off.min(text.len());
while o > 0 && !text.is_char_boundary(o) {
o -= 1;
}
o
}
/// The previous cluster boundary before `off` (0 at the start).
pub fn prev_cluster(text: &str, off: usize) -> usize {
let mut off = char_floor(text, off);
loop {
let mut c = unicode_segmentation::GraphemeCursor::new(off, text.len(), true);
off = c.prev_boundary(text, 0).ok().flatten().unwrap_or(0);
if is_cluster_boundary(text, off) {
return off;
}
}
}
/// The next cluster boundary after `off` (`text.len()` at the end).
pub fn next_cluster(text: &str, off: usize) -> usize {
let mut off = char_floor(text, off);
loop {
let mut c = unicode_segmentation::GraphemeCursor::new(off, text.len(), true);
off = c.next_boundary(text, 0).ok().flatten().unwrap_or(text.len());
if is_cluster_boundary(text, off) {
return off;
}
}
}
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
/// South and South-East Asia.
pub fn backspaces_by_char(c: char) -> bool {
matches!(u32::from(c),
0x0900..=0x0DFF // Devanagari … Sinhala
| 0x0E00..=0x0EFF // Thai, Lao
| 0x0F00..=0x0FFF // Tibetan
| 0x1000..=0x109F // Myanmar
| 0x1780..=0x17FF // Khmer
| 0x1A20..=0x1AAF // Tai Tham
| 0x1B00..=0x1B7F // Balinese
| 0xA980..=0xA9FF // Javanese, Myanmar Extended-B
| 0xAA60..=0xAA7F) // Myanmar Extended-A
}
/// A Myanmar character that starts a syllable: a consonant (with the Mon, Shan and Karen ones),
/// an independent vowel, the great sa.
fn myanmar_syllable_start(c: char) -> bool {
matches!(u32::from(c), 0x1000..=0x102A | 0x103F | 0x1050..=0x1055 | 0x105A..=0x105D | 0x1061 | 0x1065..=0x1066 | 0x106E..=0x1070 | 0x1075..=0x1081 | 0x108E)
}
/// Line-break opportunities inside Myanmar text, which has no spaces between words: before each
/// syllable (UAX #14 leaves class SA to a dictionary; Word breaks Myanmar between syllables). A
/// consonant starts a syllable unless it is stacked (after U+1039) or a final (followed, perhaps
/// after a medial, by the asat U+103A, the virama U+1039 or the dot below U+1037). Byte offsets,
/// sorted; only between two Myanmar characters.
pub fn myanmar_breaks(text: &str) -> Vec<usize> {
let chars: Vec<(usize, char)> = text.char_indices().collect();
let myanmar = |c: char| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F);
let mut out = Vec::new();
for k in 1..chars.len() {
let (Some(&(i, c)), Some(&(_, prev))) = (chars.get(k), chars.get(k - 1)) else { continue };
if !myanmar(prev) || !myanmar_syllable_start(c) || is_stacker(prev) {
continue;
}
// Skip one medial ya/ha, then look for a killer.
let mut j = k + 1;
if chars.get(j).is_some_and(|&(_, n)| matches!(n, '\u{103B}' | '\u{103E}')) {
j += 1;
}
if chars.get(j).is_some_and(|&(_, n)| matches!(n, '\u{1037}' | '\u{1039}' | '\u{103A}')) {
continue;
}
out.push(i);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// "မြန်မာစာ" (Myanmar script): မြ | န် | မာ | စာ.
const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\u{102C}";
fn split<'a>(s: &'a str, at: &[usize]) -> Vec<&'a str> {
let mut v = Vec::new();
for (n, &a) in at.iter().enumerate() {
let b = at.get(n + 1).copied().unwrap_or(s.len());
v.push(&s[a..b]);
}
v
}
#[test]
fn myanmar_vowel_signs_stay_in_their_syllable() {
// Graphemes alone would cut "မာ" before the aa sign U+102C.
assert_eq!(split(MYANMAR, &cluster_starts(MYANMAR)), vec!["မြ", "န်", "မာ", "စာ"]);
// The prevowel ေ (U+1031) follows its consonant in memory: one cluster, as is visarga း.
assert_eq!(split("ကြေး", &cluster_starts("ကြေး")), vec!["ကြေး"]);
// Stacked consonants (virama U+1039) and kinzi join the syllable they hang from.
let stacked = "\u{1019}\u{1039}\u{1018}\u{102C}"; // မ္ဘာ
assert_eq!(cluster_starts(stacked), vec![0]);
let kinzi = "\u{1021}\u{1004}\u{103A}\u{1039}\u{1002}\u{101C}\u{102D}\u{1015}\u{103A}"; // အင်္ဂလိပ်
assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]);
}
#[test]
fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() {
// ខ្មែរ: ខ + coeng + ម + ែ, then រ.
assert_eq!(split("ខ្មែរ", &cluster_starts("ខ្មែរ")), vec!["ខ្មែ", "រ"]);
// Devanagari conjuncts are grapheme clusters already.
assert_eq!(split("क्षि हि", &cluster_starts("क्षि हि")), vec!["क्षि", " ", "हि"]);
// Latin is untouched: one cluster per letter, combining accents included.
assert_eq!(split("e\u{301}a", &cluster_starts("e\u{301}a")), vec!["e\u{301}", "a"]);
}
#[test]
fn caret_steps_skip_over_whole_clusters() {
let s = MYANMAR;
assert_eq!(next_cluster(s, 0), 6);
assert_eq!(next_cluster(s, 12), 18, "past the aa sign, not before it");
assert_eq!(prev_cluster(s, 18), 12);
assert_eq!(prev_cluster(s, 0), 0);
assert_eq!(next_cluster(s, s.len()), s.len());
assert!(!is_cluster_boundary(s, 15) && is_cluster_boundary(s, 12));
// Hostile offsets: past the end, inside a character.
assert_eq!(next_cluster(s, 1_000), s.len());
assert!(!is_cluster_boundary(s, 1));
assert_eq!(next_cluster(s, 1), 6);
assert_eq!(prev_cluster(s, 13), 6);
assert_eq!(prev_cluster("", 5), 0);
assert_eq!(next_cluster("", 0), 0);
}
#[test]
fn myanmar_wraps_between_syllables() {
// မြန်မာစာ: not before န (န် is a final, killed by the asat), so before မာ and စာ.
assert_eq!(myanmar_breaks(MYANMAR), vec![12, 18]);
// Never before a stacked consonant or one followed by medial + asat.
assert!(myanmar_breaks("\u{1019}\u{1039}\u{1018}\u{102C}").is_empty());
assert!(myanmar_breaks("ကျွန်").is_empty(), "န် is the final of ကျွန်");
// Not between Latin and Myanmar (UAX #14 handles that) or inside Latin.
assert!(myanmar_breaks("abcက").is_empty());
assert!(myanmar_breaks("").is_empty());
}
#[test]
fn backspace_by_character_in_brahmic_scripts_only() {
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
}
}
-544
View File
@@ -1,544 +0,0 @@
//! Language-aware sorting of text: a small, pure-Rust collation.
//!
//! Every letter gets a *primary* weight (its place in the alphabet) and a *secondary* one (its
//! diacritic). Strings compare by primary weights first, then by secondary ones, so `é` sorts
//! with `e` (`resume` < `résumé` < `rex`) unless the language makes it a letter of its own:
//! Polish puts `ą` right after `a` and `ż` after `ź` after `z`, Swedish puts `å ä ö` after `z`,
//! Czech puts `ch` after `h`, Spanish `ñ` after `n`, Hungarian `cs`, `gy`, `sz`… after their
//! first letter. The per-language tables follow the published alphabets of those languages.
//! Text without diacritics sorts exactly as a case-insensitive code-point compare.
use std::cmp::Ordering;
/// The diacritic of a precomposed lowercase Latin letter: `(base letters, mark)`. Marks: 1 acute,
/// 2 grave, 3 circumflex, 4 diaeresis, 5 tilde, 6 ring, 7 caron, 8 cedilla, 9 ogonek, 10 dot,
/// 11 stroke, 12 breve, 13 macron, 14 double acute, 15 comma below, 16 ligature, 17 dotless,
/// 18 eth.
const DECOMP: &[(char, &str, u16)] = &[
('à', "a", 2),
('á', "a", 1),
('â', "a", 3),
('ã', "a", 5),
('ä', "a", 4),
('å', "a", 6),
('ā', "a", 13),
('ă', "a", 12),
('ą', "a", 9),
('æ', "ae", 16),
('ç', "c", 8),
('ć', "c", 1),
('ĉ', "c", 3),
('ċ', "c", 10),
('č', "c", 7),
('ď', "d", 7),
('đ', "d", 11),
('ð', "d", 18),
('è', "e", 2),
('é', "e", 1),
('ê', "e", 3),
('ë', "e", 4),
('ē', "e", 13),
('ĕ', "e", 12),
('ė', "e", 10),
('ę', "e", 9),
('ě', "e", 7),
('ĝ', "g", 3),
('ğ', "g", 12),
('ġ', "g", 10),
('ģ', "g", 8),
('ĥ', "h", 3),
('ħ', "h", 11),
('ì', "i", 2),
('í', "i", 1),
('î', "i", 3),
('ï', "i", 4),
('ĩ', "i", 5),
('ī', "i", 13),
('ĭ', "i", 12),
('į', "i", 9),
('ı', "i", 17),
('ij', "ij", 16),
('ĵ', "j", 3),
('ķ', "k", 8),
('ĺ', "l", 1),
('ļ', "l", 8),
('ľ', "l", 7),
('ŀ', "l", 10),
('ł', "l", 11),
('ñ', "n", 5),
('ń', "n", 1),
('ņ', "n", 8),
('ň', "n", 7),
('ò', "o", 2),
('ó', "o", 1),
('ô', "o", 3),
('õ', "o", 5),
('ö', "o", 4),
('ø', "o", 11),
('ō', "o", 13),
('ŏ', "o", 12),
('ő', "o", 14),
('œ', "oe", 16),
('ŕ', "r", 1),
('ŗ', "r", 8),
('ř', "r", 7),
('ś', "s", 1),
('ŝ', "s", 3),
('ş', "s", 8),
('š', "s", 7),
('ș', "s", 15),
('ß', "ss", 16),
('ţ', "t", 8),
('ť', "t", 7),
('ŧ', "t", 11),
('ț', "t", 15),
('ù', "u", 2),
('ú', "u", 1),
('û', "u", 3),
('ü', "u", 4),
('ũ', "u", 5),
('ū', "u", 13),
('ŭ', "u", 12),
('ů', "u", 6),
('ű', "u", 14),
('ų', "u", 9),
('ŵ', "w", 3),
('ý', "y", 1),
('ÿ', "y", 4),
('ŷ', "y", 3),
('ź', "z", 1),
('ż', "z", 10),
('ž', "z", 7),
];
/// A letter (or letter group) a language sorts as a letter of its own: right after `after`
/// (`rank` places after it), with secondary weight `sec` among letters of the same place.
struct Tailor {
seq: &'static str,
after: char,
rank: u32,
sec: u16,
}
const fn t(seq: &'static str, after: char, rank: u32) -> Tailor {
Tailor { seq, after, rank, sec: 0 }
}
const fn ts(seq: &'static str, after: char, rank: u32, sec: u16) -> Tailor {
Tailor { seq, after, rank, sec }
}
const PL: &[Tailor] =
&[t("ą", 'a', 1), t("ć", 'c', 1), t("ę", 'e', 1), t("ł", 'l', 1), t("ń", 'n', 1), t("ó", 'o', 1), t("ś", 's', 1), t("ź", 'z', 1), t("ż", 'z', 2)];
const CS: &[Tailor] = &[t("č", 'c', 1), t("ch", 'h', 1), t("ř", 'r', 1), t("š", 's', 1), t("ž", 'z', 1)];
const SK: &[Tailor] = &[t("ä", 'a', 1), t("č", 'c', 1), t("ch", 'h', 1), t("ô", 'o', 1), t("š", 's', 1), t("ž", 'z', 1)];
const HU: &[Tailor] = &[
t("cs", 'c', 1),
t("dz", 'd', 1),
t("dzs", 'd', 2),
t("gy", 'g', 1),
t("ly", 'l', 1),
t("ny", 'n', 1),
t("ö", 'o', 1),
ts("ő", 'o', 1, 14),
t("sz", 's', 1),
t("ty", 't', 1),
t("ü", 'u', 1),
ts("ű", 'u', 1, 14),
t("zs", 'z', 1),
];
const SV: &[Tailor] = &[t("å", 'z', 1), t("ä", 'z', 2), ts("æ", 'z', 2, 16), t("ö", 'z', 3), ts("ø", 'z', 3, 11)];
const DA: &[Tailor] = &[t("æ", 'z', 1), ts("ä", 'z', 1, 4), t("ø", 'z', 2), ts("ö", 'z', 2, 4), t("å", 'z', 3)];
const ES: &[Tailor] = &[t("ñ", 'n', 1)];
// Russian ё shares е's primary weight; the diaeresis breaks ties (CLDR Russian standard).
const RU: &[Tailor] = &[ts("ё", 'е', 0, 4)];
const TR: &[Tailor] = &[t("ç", 'c', 1), t("ğ", 'g', 1), t("ı", 'h', 1), t("ö", 'o', 1), t("ş", 's', 1), t("ü", 'u', 1)];
const HR: &[Tailor] =
&[t("č", 'c', 1), t("ć", 'c', 2), t("dž", 'd', 1), t("đ", 'd', 2), t("lj", 'l', 1), t("nj", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
const RO: &[Tailor] = &[t("ă", 'a', 1), t("â", 'a', 2), t("î", 'i', 1), t("ș", 's', 1), ts("ş", 's', 1, 8), t("ț", 't', 1), ts("ţ", 't', 1, 8)];
const ET: &[Tailor] = &[t("š", 's', 1), t("z", 's', 2), t("ž", 's', 3), t("õ", 'w', 1), t("ä", 'w', 2), t("ö", 'w', 3), t("ü", 'w', 4)];
const LT: &[Tailor] = &[t("č", 'c', 1), t("š", 's', 1), t("ž", 'z', 1)];
const LV: &[Tailor] = &[t("č", 'c', 1), t("ģ", 'g', 1), t("ķ", 'k', 1), t("ļ", 'l', 1), t("ņ", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
const IS: &[Tailor] = &[
t("á", 'a', 1),
t("ð", 'd', 1),
t("é", 'e', 1),
t("í", 'i', 1),
t("ó", 'o', 1),
t("ú", 'u', 1),
t("ý", 'y', 1),
t("þ", 'z', 1),
t("æ", 'z', 2),
t("ö", 'z', 3),
];
/// The primary language subtag of a BCP 47 tag (`pl-PL` → `pl`), lowercased.
pub fn language(lang: Option<&str>) -> String {
lang.and_then(|l| l.split(['-', '_']).next()).unwrap_or("").trim().to_ascii_lowercase()
}
fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
match language(lang).as_str() {
"pl" => PL,
"cs" => CS,
"sk" => SK,
"hu" => HU,
"sv" | "fi" => SV,
"da" | "nb" | "nn" | "no" => DA,
"es" | "gl" => ES,
"ru" => RU,
"tr" | "az" => TR,
"hr" | "bs" | "sr" => HR,
"ro" => RO,
"et" => ET,
"lt" => LT,
"lv" => LV,
"is" => IS,
"sl" => LT,
_ => &[],
}
}
/// A lowercase Latin or Russian letter followed by a combining mark, and the single letter Unicode
/// composes them to (NFC; generated from the Unicode Character Database). Text pasted from
/// decomposed sources (macOS file names: `z` + U+0307) collates like the composed letter.
const COMPOSE: &[(char, char, char)] = &[
('a', '\u{300}', 'à'),
('a', '\u{301}', 'á'),
('a', '\u{302}', 'â'),
('a', '\u{303}', 'ã'),
('a', '\u{304}', 'ā'),
('a', '\u{306}', 'ă'),
('a', '\u{307}', 'ȧ'),
('a', '\u{308}', 'ä'),
('a', '\u{30a}', 'å'),
('a', '\u{30c}', 'ǎ'),
('a', '\u{328}', 'ą'),
('b', '\u{307}', 'ḃ'),
('c', '\u{301}', 'ć'),
('c', '\u{302}', 'ĉ'),
('c', '\u{307}', 'ċ'),
('c', '\u{30c}', 'č'),
('c', '\u{327}', 'ç'),
('d', '\u{307}', 'ḋ'),
('d', '\u{30c}', 'ď'),
('d', '\u{327}', 'ḑ'),
('e', '\u{300}', 'è'),
('e', '\u{301}', 'é'),
('e', '\u{302}', 'ê'),
('e', '\u{303}', 'ẽ'),
('e', '\u{304}', 'ē'),
('e', '\u{306}', 'ĕ'),
('e', '\u{307}', 'ė'),
('e', '\u{308}', 'ë'),
('e', '\u{30c}', 'ě'),
('e', '\u{327}', 'ȩ'),
('e', '\u{328}', 'ę'),
('f', '\u{307}', 'ḟ'),
('g', '\u{301}', 'ǵ'),
('g', '\u{302}', 'ĝ'),
('g', '\u{304}', 'ḡ'),
('g', '\u{306}', 'ğ'),
('g', '\u{307}', 'ġ'),
('g', '\u{30c}', 'ǧ'),
('g', '\u{327}', 'ģ'),
('h', '\u{302}', 'ĥ'),
('h', '\u{307}', 'ḣ'),
('h', '\u{308}', 'ḧ'),
('h', '\u{30c}', 'ȟ'),
('h', '\u{327}', 'ḩ'),
('i', '\u{300}', 'ì'),
('i', '\u{301}', 'í'),
('i', '\u{302}', 'î'),
('i', '\u{303}', 'ĩ'),
('i', '\u{304}', 'ī'),
('i', '\u{306}', 'ĭ'),
('i', '\u{308}', 'ï'),
('i', '\u{30c}', 'ǐ'),
('i', '\u{328}', 'į'),
('j', '\u{302}', 'ĵ'),
('j', '\u{30c}', 'ǰ'),
('k', '\u{301}', 'ḱ'),
('k', '\u{30c}', 'ǩ'),
('k', '\u{327}', 'ķ'),
('l', '\u{301}', 'ĺ'),
('l', '\u{30c}', 'ľ'),
('l', '\u{327}', 'ļ'),
('m', '\u{301}', 'ḿ'),
('m', '\u{307}', 'ṁ'),
('n', '\u{300}', 'ǹ'),
('n', '\u{301}', 'ń'),
('n', '\u{303}', 'ñ'),
('n', '\u{307}', 'ṅ'),
('n', '\u{30c}', 'ň'),
('n', '\u{327}', 'ņ'),
('o', '\u{300}', 'ò'),
('o', '\u{301}', 'ó'),
('o', '\u{302}', 'ô'),
('o', '\u{303}', 'õ'),
('o', '\u{304}', 'ō'),
('o', '\u{306}', 'ŏ'),
('o', '\u{307}', 'ȯ'),
('o', '\u{308}', 'ö'),
('o', '\u{30b}', 'ő'),
('o', '\u{30c}', 'ǒ'),
('o', '\u{328}', 'ǫ'),
('p', '\u{301}', 'ṕ'),
('p', '\u{307}', 'ṗ'),
('r', '\u{301}', 'ŕ'),
('r', '\u{307}', 'ṙ'),
('r', '\u{30c}', 'ř'),
('r', '\u{327}', 'ŗ'),
('s', '\u{301}', 'ś'),
('s', '\u{302}', 'ŝ'),
('s', '\u{307}', 'ṡ'),
('s', '\u{30c}', 'š'),
('s', '\u{326}', 'ș'),
('s', '\u{327}', 'ş'),
('t', '\u{307}', 'ṫ'),
('t', '\u{308}', 'ẗ'),
('t', '\u{30c}', 'ť'),
('t', '\u{326}', 'ț'),
('t', '\u{327}', 'ţ'),
('u', '\u{300}', 'ù'),
('u', '\u{301}', 'ú'),
('u', '\u{302}', 'û'),
('u', '\u{303}', 'ũ'),
('u', '\u{304}', 'ū'),
('u', '\u{306}', 'ŭ'),
('u', '\u{308}', 'ü'),
('u', '\u{30a}', 'ů'),
('u', '\u{30b}', 'ű'),
('u', '\u{30c}', 'ǔ'),
('u', '\u{328}', 'ų'),
('v', '\u{303}', 'ṽ'),
('w', '\u{300}', 'ẁ'),
('w', '\u{301}', 'ẃ'),
('w', '\u{302}', 'ŵ'),
('w', '\u{307}', 'ẇ'),
('w', '\u{308}', 'ẅ'),
('w', '\u{30a}', 'ẘ'),
('x', '\u{307}', 'ẋ'),
('x', '\u{308}', 'ẍ'),
('y', '\u{300}', 'ỳ'),
('y', '\u{301}', 'ý'),
('y', '\u{302}', 'ŷ'),
('y', '\u{303}', 'ỹ'),
('y', '\u{304}', 'ȳ'),
('y', '\u{307}', 'ẏ'),
('y', '\u{308}', 'ÿ'),
('y', '\u{30a}', 'ẙ'),
('z', '\u{301}', 'ź'),
('z', '\u{302}', 'ẑ'),
('z', '\u{307}', 'ż'),
('z', '\u{30c}', 'ž'),
('е', '\u{308}', 'ё'),
('и', '\u{306}', 'й'),
];
/// `s` lowercased, with each letter + combining mark that has a single-letter form composed.
fn lower_composed(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.to_lowercase().chars() {
if let Some(prev) = out.chars().next_back()
&& let Some((_, _, composed)) = COMPOSE.iter().find(|(b, m, _)| *b == prev && *m == c)
{
out.pop();
out.push(*composed);
} else {
out.push(c);
}
}
out
}
fn weight(c: char, rank: u32) -> u32 {
((c as u32) << 4).saturating_add(rank)
}
/// One collation element: primary weight, secondary weight, the text it covers.
struct Elem<'a> {
primary: Vec<u32>,
sec: u16,
text: &'a str,
/// Set when the text is a letter of its own in the language (shown as is in headings).
own: bool,
}
/// The next collation element of `rest` (lowercase), and its length in bytes.
fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> {
if let Some(r) = rules.iter().filter(|r| rest.starts_with(r.seq)).max_by_key(|r| r.seq.len()) {
let text = rest.get(..r.seq.len())?;
return Some((Elem { primary: vec![weight(r.after, r.rank)], sec: r.sec, text, own: true }, r.seq.len()));
}
let c = rest.chars().next()?;
let n = c.len_utf8();
let text = rest.get(..n)?;
if let Some((_, base, mark)) = DECOMP.iter().find(|(x, _, _)| *x == c) {
return Some((Elem { primary: base.chars().map(|b| weight(b, 0)).collect(), sec: *mark, text, own: false }, n));
}
// A combining mark adds a secondary difference to the letter before it.
if ('\u{300}'..='\u{36f}').contains(&c) {
return Some((Elem { primary: Vec::new(), sec: 32 + (c as u32 - 0x300) as u16, text, own: false }, n));
}
Some((Elem { primary: vec![weight(c, 0)], sec: 0, text, own: false }, n))
}
/// A sort key: compare keys to sort strings in a language's order. Equal keys mean the strings
/// differ at most in letter case.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct SortKey {
primary: Vec<u32>,
secondary: Vec<u16>,
text: String,
}
/// The sort key of `s` for language `lang` (a BCP 47 tag such as `pl-PL`; `None` sorts like
/// English).
pub fn sort_key(s: &str, lang: Option<&str>) -> SortKey {
let text = lower_composed(s);
let rules = tailoring(lang);
let mut key = SortKey::default();
let mut i = 0;
while let Some(rest) = text.get(i..).filter(|r| !r.is_empty()) {
let Some((e, n)) = next_elem(rest, rules) else { break };
i += n.max(1);
if e.primary.is_empty() {
if let Some(last) = key.secondary.last_mut() {
*last = last.saturating_add(e.sec);
}
continue;
}
for (k, w) in e.primary.iter().enumerate() {
key.primary.push(*w);
key.secondary.push(if k == 0 { e.sec } else { 0 });
}
}
key.text = text;
key
}
/// Compare two strings in the order of language `lang`, ignoring case.
pub fn collate(a: &str, b: &str, lang: Option<&str>) -> Ordering {
sort_key(a, lang).cmp(&sort_key(b, lang))
}
/// The index heading letter `s` files under in language `lang`: `Ł` for `Łódź` in Polish, `L`
/// in English; `Ch` for `chata` in Czech; `A` for `Äpfel` in German, `Ä` in Swedish.
pub fn heading_letter(s: &str, lang: Option<&str>) -> Option<String> {
let low = lower_composed(s.trim_start());
let (e, _) = next_elem(&low, tailoring(lang))?;
let shown = if e.own || e.sec == 0 {
e.text.to_string()
} else {
DECOMP.iter().find(|(x, _, _)| e.text.starts_with(*x)).map(|(_, b, _)| b.chars().take(1).collect()).unwrap_or_else(|| e.text.to_string())
};
let mut cs = shown.chars();
let first = cs.next()?;
Some(first.to_uppercase().chain(cs).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn sorted(words: &[&str], lang: &str) -> Vec<String> {
let mut v: Vec<String> = words.iter().map(|w| w.to_string()).collect();
v.sort_by(|a, b| collate(a, b, Some(lang)));
v
}
#[test]
fn polish_letters_follow_their_base_letter() {
assert_eq!(sorted(&["Zakopane", "Łódź", "Kraków"], "pl-PL"), ["Kraków", "Łódź", "Zakopane"]);
assert_eq!(sorted(&["zebra", "ćma", "dom"], "pl-PL"), ["ćma", "dom", "zebra"]);
assert_eq!(sorted(&["żaba", "źle", "zebra", "ząb", "lato", "łapa", "mama"], "pl"), ["lato", "łapa", "mama", "ząb", "zebra", "źle", "żaba"]);
assert_eq!(sorted(&["b", "ą", "a"], "pl"), ["a", "ą", "b"]);
// Every Polish letter is a letter of its own, after its base letter (review of #844).
assert_eq!(sorted(&["Malbork", "Łódź", "Lublin"], "pl"), ["Lublin", "Łódź", "Malbork"]);
assert_eq!(sorted(&["ósmy", "owca", "pies"], "pl"), ["owca", "ósmy", "pies"]);
assert_eq!(sorted(&["ściana", "sum", "tak"], "pl"), ["sum", "ściana", "tak"]);
assert_eq!(sorted(&["ęsi", "ezop", "fala"], "pl"), ["ezop", "ęsi", "fala"]);
assert_eq!(sorted(&["ńa", "nz", "oko"], "pl"), ["nz", "ńa", "oko"]);
assert_eq!(heading_letter("ósmy", Some("pl")).as_deref(), Some("Ó"));
assert_eq!(heading_letter("ńa", Some("pl")).as_deref(), Some("Ń"));
// Decomposed letters (`z` + combining dot above, as macOS file names write `ż`) sort like
// the composed ones.
assert_eq!(collate("z\u{307}aba", "żaba", Some("pl-PL")), Ordering::Equal);
assert_eq!(collate("z\u{307}aba", "zebra", Some("pl-PL")), Ordering::Greater);
assert_eq!(heading_letter("Z\u{307}aba", Some("pl")).as_deref(), Some("Ż"));
assert_eq!(collate("a\u{328}", "ą", Some("pl")), Ordering::Equal);
}
#[test]
fn swedish_puts_a_ring_and_umlauts_after_z() {
assert_eq!(sorted(&["ödla", "zebra", "äpple", "åka", "apa"], "sv-SE"), ["apa", "zebra", "åka", "äpple", "ödla"]);
}
#[test]
fn german_sorts_umlauts_with_their_base_letter() {
assert_eq!(sorted(&["Zug", "Äpfel", "Apfel", "Bach"], "de-DE"), ["Apfel", "Äpfel", "Bach", "Zug"]);
assert_eq!(sorted(&["Straße", "Strasse", "Strand"], "de"), ["Strand", "Strasse", "Straße"]);
}
#[test]
fn russian_yo_sorts_with_e_instead_of_after_ya() {
for lang in ["ru", "ru-RU", "RU_ru"] {
assert_eq!(sorted(&["яблоко", "ёлка", "жук", "ель", "ёж", "еж"], lang), ["еж", "ёж", "ёлка", "ель", "жук", "яблоко"]);
assert_eq!(collate("ЁЛКА", "ёлка", Some(lang)), Ordering::Equal);
assert_eq!(heading_letter("Ёлка", Some(lang)).as_deref(), Some("Ё"));
}
}
#[test]
fn russian_decomposed_letters_match_their_composed_forms() {
for (composed, decomposed) in [("ёлка", "е\u{308}лка"), ("Ёлка", "Е\u{308}лка"), ("йод", "и\u{306}од"), ("Йод", "И\u{306}од")]
{
assert_eq!(collate(composed, decomposed, Some("ru-RU")), Ordering::Equal);
assert_eq!(heading_letter(composed, Some("ru")), heading_letter(decomposed, Some("ru")));
}
assert_eq!(sorted(&["к", "и\u{306}", "и"], "ru"), ["и", "и\u{306}", "к"]);
}
#[test]
fn spanish_n_tilde_after_n() {
assert_eq!(sorted(&["oso", "ñu", "nube"], "es-ES"), ["nube", "ñu", "oso"]);
}
#[test]
fn czech_slovak_hungarian_alphabets() {
assert_eq!(sorted(&["chata", "hrad", "ihla", "cesta", "čaj", "dům"], "cs-CZ"), ["cesta", "čaj", "dům", "hrad", "chata", "ihla"]);
assert_eq!(sorted(&["ôsmy", "oko", "pes", "ä", "b"], "sk"), ["ä", "b", "oko", "ôsmy", "pes"]);
assert_eq!(sorted(&["csak", "cukor", "dob", "szó", "sűrű", "tál"], "hu-HU"), ["cukor", "csak", "dob", "sűrű", "szó", "tál"]);
// ö and ő are one letter after o, told apart by the accent only.
assert_eq!(sorted(&["ötös", "őr", "pálya", "olló", "ör"], "hu"), ["olló", "ör", "őr", "ötös", "pálya"]);
}
#[test]
fn english_keeps_case_insensitive_order_and_folds_accents() {
assert_eq!(sorted(&["rex", "résumé", "resume", "Apple", "banana"], "en-US"), ["Apple", "banana", "resume", "résumé", "rex"]);
assert_eq!(collate("Łódź", "Lodz", None), Ordering::Greater);
assert_eq!(collate("Łódź", "Lodzia", None), Ordering::Less);
assert_eq!(collate("ABC", "abc", None), Ordering::Equal);
}
#[test]
fn heading_letters() {
assert_eq!(heading_letter("Łódź", Some("pl-PL")).as_deref(), Some("Ł"));
assert_eq!(heading_letter("Łódź", Some("en-US")).as_deref(), Some("L"));
assert_eq!(heading_letter("chata", Some("cs")).as_deref(), Some("Ch"));
assert_eq!(heading_letter("Äpfel", Some("de")).as_deref(), Some("A"));
assert_eq!(heading_letter("Äpple", Some("sv")).as_deref(), Some("Ä"));
assert_eq!(heading_letter("", Some("sv")), None);
}
#[test]
fn hostile_input_never_panics() {
for s in ["", "\u{301}", "a\u{301}\u{301}\u{301}", "dzs", "\u{10FFFF}", "ẞ", "İstanbul"] {
for l in [None, Some("hu"), Some(""), Some("-"), Some("tr-TR")] {
let _ = sort_key(s, l);
let _ = heading_letter(s, l);
}
}
}
}
-579
View File
@@ -1,579 +0,0 @@
//! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text
//! warp, a rounded rectangle's corner, and a connector's ends.
//!
//! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line
//! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be
//! glued to a connection site of another shape (`a:stCxn` / `a:endCxn`, ECMA-376 §20.1.2.2.36 and
//! §20.1.2.2.13: the shape's drawing id and a site index). When a glued shape moves, [`route`]
//! gives the connector's new frame from its ends.
use serde::{Deserialize, Serialize};
use crate::graphic::PathSeg;
use crate::para::{InlineObject, ShapeKind};
use crate::wordart::TextWarp;
/// DrawingML outline ends (ST_LineCap); missing and unknown values use flat ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineCap {
#[default]
Flat,
Square,
Round,
}
impl LineCap {
/// Read an ST_LineCap value; absent or unrecognized values use the flat default.
pub fn from_ooxml(value: &str) -> Self {
match value {
"sq" => Self::Square,
"rnd" => Self::Round,
_ => Self::Flat,
}
}
/// Return the canonical DrawingML spelling used when saving the outline.
pub fn ooxml(self) -> &'static str {
match self {
Self::Flat => "flat",
Self::Square => "sq",
Self::Round => "rnd",
}
}
/// Whether the cap can be omitted from older-compatible serialized shape properties.
pub fn is_flat(&self) -> bool {
*self == Self::Flat
}
}
/// The rest of a shape's properties. Empty (the default) for most shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ShapeExtra {
/// The drawing id connectors refer to (`wp:docPr/@id`, `wps:cNvPr/@id`); 0 = none. Kept only
/// for shapes a connector is glued to.
#[serde(skip_serializing_if = "is_zero")]
pub id: u32,
/// The text's warp (Text Effects › Transform).
#[serde(skip_serializing_if = "Option::is_none")]
pub warp: Option<TextWarp>,
/// What a connector's start and end are glued to.
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<ConnEnd>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<ConnEnd>,
/// Arrowheads at a line's or connector's start (`a:headEnd`) and end (`a:tailEnd`).
#[serde(skip_serializing_if = "is_false")]
pub arrow_start: bool,
#[serde(skip_serializing_if = "is_false")]
pub arrow_end: bool,
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
#[serde(skip_serializing_if = "Option::is_none")]
pub corner: Option<u32>,
/// The outline dash pattern; absent in older saved documents means solid.
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
pub dash: crate::line_dash::LineDash,
/// The outline ends; older saved documents and absent DrawingML caps are flat.
#[serde(skip_serializing_if = "LineCap::is_flat")]
pub cap: LineCap,
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
pub outline_fill: Option<crate::fill::Fill>,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
fn is_false(v: &bool) -> bool {
!*v
}
impl ShapeExtra {
pub fn is_empty(&self) -> bool {
*self == ShapeExtra::default()
}
}
/// The largest rounded-rectangle corner: half the shorter side, which makes its ends round.
pub const MAX_CORNER: u32 = 50_000;
/// A rounded rectangle's corner radius without its own: 16% of the shorter side.
pub const DEFAULT_CORNER: f32 = 0.16;
/// The corner radius, in points, of a `w` × `h` rounded rectangle with `corner` (see
/// [`ShapeExtra::corner`]).
pub fn corner_radius(corner: Option<u32>, w: f32, h: f32) -> f32 {
let side = wordcraft_geom::finite(w).min(wordcraft_geom::finite(h)).max(0.0);
side * corner.map_or(DEFAULT_CORNER, |c| c.min(MAX_CORNER) as f32 / 100_000.0)
}
/// One glued end of a connector: the drawing id of the shape and its connection site index.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnEnd {
pub id: u32,
pub site: u32,
}
/// Largest connection site index read from a file.
pub const MAX_SITE: u32 = 64;
impl ShapeKind {
/// A connector (straight, elbow or curved): a line that can be glued to shapes.
pub fn is_connector(self) -> bool {
matches!(self, ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector)
}
/// A line or connector: drawn as an open path, never filled.
pub fn is_open(self) -> bool {
self == ShapeKind::Line || self.is_connector()
}
}
/// The connection sites of a `kind` shape `w` × `h`, in its own unrotated frame, by index. The
/// order follows the preset shapes' connection lists (for a rectangle: top, left, bottom, right).
pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
let (cx, cy) = (w / 2.0, h / 2.0);
match kind {
ShapeKind::Ellipse => {
// The four sides, and the points of the ellipse at 45° between them.
let (dx, dy) = (cx * std::f32::consts::FRAC_1_SQRT_2, cy * std::f32::consts::FRAC_1_SQRT_2);
vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)]
}
ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)],
// The corners, the legs' midpoints and the hypotenuse's.
ShapeKind::RightTriangle => vec![(0.0, 0.0), (0.0, cy), (0.0, h), (cx, h), (w, h), (cx, cy)],
ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)],
_ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)],
}
}
/// Site `site` of a `kind` shape with frame `rect` (x, y, w, h) on the page, turned and flipped by
/// `spin` about its centre. `None` for a site the shape doesn't have.
pub fn site_point(kind: ShapeKind, rect: [f32; 4], spin: wordcraft_geom::Spin, site: u32) -> Option<(f32, f32)> {
let [x, y, w, h] = rect;
let (sx, sy) = *sites(kind, w, h).get(site as usize)?;
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
let (px, py) = spin.apply(cx, cy, x + sx, y + sy);
(px.is_finite() && py.is_finite()).then_some((px, py))
}
/// The site of a `kind` shape (frame `rect`, `spin`) nearest to page point `at`: its index, point
/// and distance.
pub fn nearest_site(kind: ShapeKind, rect: [f32; 4], spin: wordcraft_geom::Spin, at: (f32, f32)) -> Option<(u32, (f32, f32), f32)> {
let n = sites(kind, rect[2], rect[3]).len() as u32;
(0..n)
.filter_map(|i| site_point(kind, rect, spin, i).map(|p| (i, p, ((p.0 - at.0).powi(2) + (p.1 - at.1).powi(2)).sqrt())))
.min_by(|a, b| a.2.total_cmp(&b.2))
}
/// A connector's frame: top-left corner, size and flips.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Frame {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub flip_h: bool,
pub flip_v: bool,
}
/// The frame of a connector from `start` to `end`.
pub fn route(start: (f32, f32), end: (f32, f32)) -> Frame {
let f = wordcraft_geom::finite;
let (x0, y0, x1, y1) = (f(start.0), f(start.1), f(end.0), f(end.1));
Frame { x: x0.min(x1), y: y0.min(y1), w: (x1 - x0).abs(), h: (y1 - y0).abs(), flip_h: x1 < x0, flip_v: y1 < y0 }
}
/// A connector's start and end from its frame (the inverse of [`route`]).
pub fn ends(x: f32, y: f32, w: f32, h: f32, flip_h: bool, flip_v: bool) -> ((f32, f32), (f32, f32)) {
let (xa, xb) = if flip_h { (x + w, x) } else { (x, x + w) };
let (ya, yb) = if flip_v { (y + h, y) } else { (y, y + h) };
((xa, ya), (xb, yb))
}
/// The path of a connector `kind` in a `w` × `h` frame at the origin, from its top-left to its
/// bottom-right corner (flips are the frame's turn): straight, an elbow through the middle (out
/// horizontally, across, in horizontally), or an S-curve. `None` for other kinds.
pub fn connector_segs(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<PathSeg>> {
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
let mx = w / 2.0;
Some(match kind {
ShapeKind::StraightConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(w, h)],
ShapeKind::ElbowConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(mx, 0.0), PathSeg::Line(mx, h), PathSeg::Line(w, h)],
ShapeKind::CurvedConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Cubic(mx, 0.0, mx, h, w, h)],
_ => return None,
})
}
/// The direction (unit vector) a line or connector `kind` arrives at its end (`at_end`) or
/// leaves its start, in its unflipped `w` × `h` frame: where an arrowhead points.
pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32) {
let (dx, dy) = match kind {
ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => {
if w.abs() > 0.01 || h.abs() < 0.01 {
(1.0, 0.0)
} else {
(0.0, 1.0)
}
}
ShapeKind::Line => (w, -h),
_ => (w, h),
};
let len = (dx * dx + dy * dy).sqrt();
let (ux, uy) = if len > 1e-6 && len.is_finite() { (dx / len, dy / len) } else { (1.0, 0.0) };
if at_end { (ux, uy) } else { (-ux, -uy) }
}
/// Re-route a group's glued connectors. Resolved ends use the shapes' connection sites in child
/// coordinates; free or unresolved ends keep their displayed position after child scaling and spin.
/// Returns whether any moved. Non-groups and connectors needing a non-invertible or display-capped
/// fallback are unchanged.
pub fn reroute_group(group: &mut InlineObject) -> bool {
let InlineObject::Group { w, h, ch_w, ch_h, children, .. } = group else { return false };
let fin = wordcraft_geom::finite;
// Match group_rects: invalid child extents use unit scale; invalid display extents become zero.
let sx = if fin(*ch_w) > 0.0 { fin(*w) / *ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(*h) / *ch_h } else { 1.0 };
let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children
.iter()
.filter_map(|c| match &c.obj {
InlineObject::Shape { kind, w, h, float, extra, .. } if extra.id != 0 => Some((extra.id, *kind, [c.x, c.y, *w, *h], float.spin())),
_ => None,
})
.collect();
if shapes.is_empty() {
return false;
}
let find = |e: Option<ConnEnd>| {
let e = e?;
let (_, kind, rect, spin) = shapes.iter().find(|s| s.0 == e.id)?;
site_point(*kind, *rect, *spin, e.site)
};
let mut moved = false;
for c in children.iter_mut() {
let InlineObject::Shape { kind, w, h, float, extra, .. } = &mut c.obj else { continue };
if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) {
continue;
}
let (s, e, fallback) = match (find(extra.start), find(extra.end)) {
(Some(s), Some(e)) => (s, e, false),
(s, e) => {
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
continue;
}
// Rendering scales the rectangle before spinning it. Undo only the group scale
// so route can rebuild an unrotated child frame without moving fallback ends.
let (x, y) = (fin(c.x) * sx, fin(c.y) * sy);
let (cw, ch) = ((fin(*w) * sx).clamp(0.0, 4000.0), (fin(*h) * sy).clamp(0.0, 4000.0));
let (cx, cy) = (x + cw / 2.0, y + ch / 2.0);
let spin = float.spin();
let s0 = spin.apply(cx, cy, x, y);
let e0 = spin.apply(cx, cy, x + cw, y + ch);
(s.unwrap_or((s0.0 / sx, s0.1 / sy)), e.unwrap_or((e0.0 / sx, e0.1 / sy)), true)
}
};
if ![s.0, s.1, e.0, e.1].into_iter().all(f32::is_finite) {
continue;
}
let f = route(s, e);
if ![f.x, f.y, f.w, f.h].into_iter().all(f32::is_finite) {
continue;
}
// group_rects caps displayed child sizes. A larger fallback span cannot be represented
// by an unrotated frame, so keep the original instead of moving its free/unresolved end.
if fallback && (f.w * sx > 4000.0 || f.h * sy > 4000.0) {
continue;
}
let same = (f.x - c.x).abs() < 0.01 && (f.y - c.y).abs() < 0.01 && (f.w - *w).abs() < 0.01 && (f.h - *h).abs() < 0.01;
if same && f.flip_h == float.flip_h && f.flip_v == float.flip_v && float.rot == 0.0 {
continue;
}
c.x = f.x;
c.y = f.y;
*w = f.w;
*h = f.h;
float.rot = 0.0;
float.flip_h = f.flip_h;
float.flip_v = f.flip_v;
moved = true;
}
moved
}
#[cfg(test)]
mod tests {
use super::*;
use crate::para::{Float, GroupChild};
/// Older JSON retains flat ends without adding a property; non-flat values round-trip.
#[test]
fn older_shape_json_defaults_to_flat_caps() {
let extra: ShapeExtra = serde_json::from_str("{}").unwrap();
assert_eq!(extra.cap, LineCap::Flat);
assert_eq!(serde_json::to_value(&extra).unwrap(), serde_json::json!({}));
for cap in [LineCap::Square, LineCap::Round] {
let extra = ShapeExtra { cap, ..Default::default() };
assert_eq!(serde_json::from_value::<ShapeExtra>(serde_json::to_value(&extra).unwrap()).unwrap(), extra);
}
}
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
InlineObject::Shape {
kind,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Float::default(),
story: None,
freeform: None,
effects: Default::default(),
extra,
}
}
fn group(children: Vec<GroupChild>) -> InlineObject {
InlineObject::Group { w: 200.0, h: 300.0, ch_w: 200.0, ch_h: 300.0, float: Float::default(), children, canvas: None }
}
#[test]
fn route_and_ends_are_inverse() {
for (s, e) in [((0.0, 0.0), (10.0, 5.0)), ((10.0, 5.0), (0.0, 0.0)), ((3.0, 9.0), (7.0, 1.0))] {
let f = route(s, e);
assert_eq!(ends(f.x, f.y, f.w, f.h, f.flip_h, f.flip_v), (s, e));
}
}
#[test]
fn corner_radius_follows_the_shorter_side() {
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
assert!(near(corner_radius(None, 200.0, 100.0), 16.0));
assert!(near(corner_radius(Some(0), 200.0, 100.0), 0.0));
assert!(near(corner_radius(Some(3_565), 100.0, 200.0), 3.565));
assert!(near(corner_radius(Some(MAX_CORNER), 200.0, 100.0), 50.0));
assert!(near(corner_radius(Some(u32::MAX), 200.0, 100.0), 50.0), "capped at a half-circle end");
assert!(near(corner_radius(Some(10_000), f32::NAN, 100.0), 0.0));
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
}
#[test]
fn rerouting_preserves_transformed_free_and_unresolved_ends() {
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
for rot in [0.0_f32, 30.0, 90.0, 180.0, 270.0, -90.0, 450.0] {
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
for free_start in [false, true] {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = rot;
float.flip_h = flip_h;
float.flip_v = flip_v;
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
// clockwise in y-down group space. Do not use the routing transform
// to compute the expectation.
let sign = if free_start { -1.0 } else { 1.0 };
let dx = sign * if flip_h { -20.0 } else { 20.0 };
let dy = sign * if flip_v { -10.0 } else { 10.0 };
let (sin, cos) = rot.to_radians().sin_cos();
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
let kids = vec![
GroupChild {
x: 100.0,
y: 200.0,
obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }),
},
GroupChild { x: 10.0, y: 30.0, obj },
];
let mut group = group(kids);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
let (free, attached) = if free_start { (start, end) } else { (end, start) };
assert!(
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
);
assert_eq!(attached, (140.0, 210.0));
assert_eq!(float.rot, 0.0);
assert!(!reroute_group(&mut group), "rerouting must settle");
}
}
}
}
}
}
#[test]
fn rerouting_preserves_both_unresolved_ends() {
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = 90.0;
let kids = vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
];
let mut group = group(kids);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
assert!((start.0 - 40.0).abs() < 0.001 && (start.1 - 20.0).abs() < 0.001);
assert!((end.0 - 20.0).abs() < 0.001 && (end.1 - 60.0).abs() < 0.001);
assert_eq!(float.rot, 0.0);
assert!(!reroute_group(&mut group));
}
#[test]
fn rerouting_nonuniform_group_preserves_both_unresolved_ends() {
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = 90.0;
let mut group = group(vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
]);
let InlineObject::Group { w, h, .. } = &mut group else { panic!() };
(*w, *h) = (400.0, 150.0);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
// Displayed rect (20, 15, 80, 10), spun 90 degrees: (65, -20) to (55, 60).
assert!((start.0 - 32.5).abs() < 0.001 && (start.1 + 40.0).abs() < 0.001);
assert!((end.0 - 27.5).abs() < 0.001 && (end.1 - 120.0).abs() < 0.001);
assert!(!reroute_group(&mut group));
}
#[test]
fn rerouting_preserves_fallback_when_group_display_cap_prevents_route() {
for free_start in [false, true] {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (None, attached) } else { (attached, None) };
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 0.0, ShapeExtra { start, end, ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.flip_h = free_start;
let mut group = group(vec![
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 3000.0, y: 10.0, obj },
]);
let InlineObject::Group { w, .. } = &mut group else { panic!() };
*w = 400.0;
let visual_free = |group: &InlineObject| {
let rects = group.group_rects(0.0, 0.0, 400.0, 300.0);
let ([x, y, w, h], obj) = rects[1];
let spin = obj.frame().unwrap().2.spin();
let (px, py) = if free_start { (x, y) } else { (x + w, y + h) };
spin.apply(x + w / 2.0, y + h / 2.0, px, py)
};
let before = visual_free(&group);
assert_eq!(before, (6080.0, 10.0));
let moved = reroute_group(&mut group);
assert_eq!(visual_free(&group), before, "the rebuilt display frame cannot span both endpoints");
assert!(!moved, "an unrepresentable fallback route must leave the connector unchanged");
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert_eq!((c.x, c.y, *w, *h, float.rot, float.flip_h), (3000.0, 10.0, 40.0, 0.0, 0.0, free_start));
assert!(!reroute_group(&mut group), "repeated rerouting must not shrink the connector");
}
}
#[test]
fn rerouting_group_handles_degenerate_dimensions_without_nonfinite_writes() {
for dims in [
[0.0, 300.0, 200.0, 300.0],
[200.0, 0.0, 200.0, 300.0],
[-200.0, 300.0, 200.0, 300.0],
[f32::NAN, 300.0, 200.0, 300.0],
[200.0, f32::INFINITY, 200.0, 300.0],
[f32::MAX, 300.0, f32::MIN_POSITIVE, 300.0],
[f32::MIN_POSITIVE, 300.0, f32::MAX, 300.0],
[20000.0, f32::MIN_POSITIVE, 200.0, 300.0],
[200.0, 300.0, 0.0, 0.0],
[200.0, 300.0, f32::NAN, f32::INFINITY],
[200.0, 300.0, -200.0, -300.0],
] {
let mut obj =
shape(ShapeKind::StraightConnector, 40.0, 20.0, ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = 90.0;
let mut group = group(vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
]);
let InlineObject::Group { w, h, ch_w, ch_h, .. } = &mut group else { panic!() };
(*w, *h, *ch_w, *ch_h) = (dims[0], dims[1], dims[2], dims[3]);
let moved = reroute_group(&mut group);
// group_rects treats invalid child extents as unit scale; display scales that
// cannot be inverted (or whose inverse overflows) must not change the connector.
assert_eq!(moved, dims[2] <= 0.0 || !dims[2].is_finite(), "{dims:?}");
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert!([c.x, c.y, *w, *h, float.rot].into_iter().all(f32::is_finite), "{dims:?}");
assert!(!reroute_group(&mut group), "rerouting must settle: {dims:?}");
}
assert!(!reroute_group(&mut shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra::default())));
}
#[test]
fn group_connectors_follow_their_shapes() {
let a = ShapeExtra { id: 7, ..Default::default() };
let b = ShapeExtra { id: 9, ..Default::default() };
let link = ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), end: Some(ConnEnd { id: 9, site: 2 }), ..Default::default() };
let mut kids = vec![
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 20.0, 10.0, a) },
GroupChild { x: 100.0, y: 50.0, obj: shape(ShapeKind::Ellipse, 20.0, 10.0, b) },
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::ElbowConnector, 1.0, 1.0, link) },
];
// Fully resolved endpoints must still come from their sites, regardless of the
// connector's old rotation and flips.
let InlineObject::Shape { float, .. } = &mut kids[2].obj else { panic!() };
float.rot = 90.0;
float.flip_h = true;
float.flip_v = true;
let mut group = group(kids);
assert!(reroute_group(&mut group));
// From the rectangle's right side (20, 5) to the ellipse's left side (100, 55).
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[2];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert_eq!((c.x, c.y, *w, *h, float.flip_h, float.flip_v), (20.0, 5.0, 80.0, 50.0, false, false));
assert!(!reroute_group(&mut group), "settled");
// A fully resolved connector does not need an invertible group scale.
for (width, height) in [(400.0, 150.0), (0.0, 0.0), (f32::NAN, f32::INFINITY)] {
let mut scaled = group.clone();
let InlineObject::Group { w, h, children, .. } = &mut scaled else { panic!() };
(*w, *h) = (width, height);
let InlineObject::Shape { float, .. } = &mut children[2].obj else { panic!() };
float.rot = 90.0;
assert!(reroute_group(&mut scaled));
let InlineObject::Group { children, .. } = &scaled else { panic!() };
let c = &children[2];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert_eq!(ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v), ((20.0, 5.0), (100.0, 55.0)));
assert!(!reroute_group(&mut scaled));
}
// A connection to a missing id or site keeps that end where it was.
let InlineObject::Group { children, .. } = &mut group else { panic!() };
if let InlineObject::Shape { extra, .. } = &mut children[2].obj {
extra.end = Some(ConnEnd { id: 404, site: 0 });
extra.start = Some(ConnEnd { id: 7, site: 99 });
}
assert!(!reroute_group(&mut group));
}
}
-754
View File
@@ -1,754 +0,0 @@
//! Content controls (`w:sdt`, ECMA-376 §17.5.2): fields of a form or template — rich and plain
//! text, check boxes, combo boxes and drop-down lists, date pickers, pictures, building block
//! galleries and repeating sections.
//!
//! A control around text is a [`InlineObject::ControlStart`] … [`InlineObject::ControlEnd`]
//! marker pair in the paragraph text, like a range field: its content is the ordinary content
//! between the markers, possibly spanning paragraphs (a block-level control sits around whole
//! paragraphs: its start at the start of the first, its end at the end of the last). Pairs nest
//! like brackets and never cross a block list. A control whose content starts or ends with a
//! table, and controls around table rows or cells, are kept on the table, row or cell
//! ([`ControlWrap`]) for the round trip.
//!
//! Editing can leave a marker without its partner. Deletions keep a marker whose partner is
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::para::OBJ;
use crate::{Block, Blocks, Document, InlineObject, Path, Pos, StoryRef};
/// Deepest table nesting scanned (matches `walk` in the crate root).
const MAX_DEPTH: usize = 16;
/// Longest tag, title or list text kept from a file.
pub const MAX_CONTROL_TEXT: usize = 1024;
/// Most list entries a combo box or drop-down list keeps.
pub const MAX_LIST_ITEMS: usize = 1000;
/// What may be done to a control (`w:lock`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ControlLock {
#[default]
Unlocked,
/// The control can't be deleted; its content can be edited.
SdtLocked,
/// The content can't be edited; the control can be deleted.
ContentLocked,
/// Neither.
SdtContentLocked,
}
impl ControlLock {
pub fn from_ooxml(v: &str) -> ControlLock {
match v {
"sdtLocked" => ControlLock::SdtLocked,
"contentLocked" => ControlLock::ContentLocked,
"sdtContentLocked" => ControlLock::SdtContentLocked,
_ => ControlLock::Unlocked,
}
}
/// The `w:lock` value, `None` when unlocked.
pub fn ooxml(self) -> Option<&'static str> {
match self {
ControlLock::Unlocked => None,
ControlLock::SdtLocked => Some("sdtLocked"),
ControlLock::ContentLocked => Some("contentLocked"),
ControlLock::SdtContentLocked => Some("sdtContentLocked"),
}
}
pub fn from_flags(no_delete: bool, no_edit: bool) -> ControlLock {
match (no_delete, no_edit) {
(false, false) => ControlLock::Unlocked,
(true, false) => ControlLock::SdtLocked,
(false, true) => ControlLock::ContentLocked,
(true, true) => ControlLock::SdtContentLocked,
}
}
/// The control itself can be deleted.
pub fn can_delete(self) -> bool {
matches!(self, ControlLock::Unlocked | ControlLock::ContentLocked)
}
/// Its content can be edited.
pub fn can_edit(self) -> bool {
matches!(self, ControlLock::Unlocked | ControlLock::SdtLocked)
}
}
/// An entry of a combo box or drop-down list.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct ListItem {
/// What the control shows.
pub display: String,
/// What it stores (`w:value`); the display text when empty.
pub value: String,
}
impl ListItem {
pub fn value(&self) -> &str {
if self.value.is_empty() { &self.display } else { &self.value }
}
}
/// The kind of a content control and its settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ControlKind {
/// Formatted text, paragraphs, anything (also controls of a type we don't model, whose type
/// element is kept in [`ContentControl::extra`]).
#[default]
RichText,
/// Unformatted text (`w:text`), on one line unless `multi_line`.
#[serde(rename_all = "camelCase")]
Text {
#[serde(default)]
multi_line: bool,
},
/// A check box (`w14:checkbox`): the content is the state's symbol.
#[serde(rename_all = "camelCase")]
CheckBox { checked: bool, checked_char: char, checked_font: String, unchecked_char: char, unchecked_font: String },
/// A list to choose from, or type into (`w:comboBox`).
#[serde(rename_all = "camelCase")]
ComboBox {
items: Vec<ListItem>,
#[serde(default)]
last_value: String,
},
/// A list to choose from (`w:dropDownList`).
#[serde(rename_all = "camelCase")]
DropDown {
items: Vec<ListItem>,
#[serde(default)]
last_value: String,
},
/// A date (`w:date`): `full_date` is ISO 8601, `format` a date picture (`M/d/yyyy`).
#[serde(rename_all = "camelCase")]
Date {
#[serde(default)]
full_date: String,
#[serde(default)]
format: String,
#[serde(default)]
lid: String,
#[serde(default)]
calendar: String,
#[serde(default)]
store_as: String,
},
/// A picture (`w:picture`).
Picture,
/// A building block gallery (`w:docPartList`) or a building block placed from one
/// (`w:docPartObj`: tables of contents, cover pages, bibliographies).
#[serde(rename_all = "camelCase")]
Gallery {
/// `w:docPartList` rather than `w:docPartObj`.
#[serde(default)]
list: bool,
#[serde(default)]
gallery: String,
#[serde(default)]
category: String,
#[serde(default)]
unique: bool,
},
/// A repeating section (`w15:repeatingSection`), whose items are repeated.
#[serde(rename_all = "camelCase")]
RepeatingSection {
#[serde(default)]
title: String,
#[serde(default)]
no_insert_delete: bool,
},
/// One item of a repeating section (`w15:repeatingSectionItem`).
RepeatingSectionItem,
}
/// Default check box symbols: ballot box with X, ballot box.
pub const CHECKED: char = '\u{2612}';
pub const UNCHECKED: char = '\u{2610}';
impl ControlKind {
pub fn check_box() -> ControlKind {
ControlKind::CheckBox {
checked: false,
checked_char: CHECKED,
checked_font: String::new(),
unchecked_char: UNCHECKED,
unchecked_font: String::new(),
}
}
/// The command-friendly name (`richText`, `checkBox`…).
pub fn name(&self) -> &'static str {
match self {
ControlKind::RichText => "richText",
ControlKind::Text { .. } => "plainText",
ControlKind::CheckBox { .. } => "checkBox",
ControlKind::ComboBox { .. } => "comboBox",
ControlKind::DropDown { .. } => "dropDown",
ControlKind::Date { .. } => "date",
ControlKind::Picture => "picture",
ControlKind::Gallery { .. } => "buildingBlock",
ControlKind::RepeatingSection { .. } => "repeatingSection",
ControlKind::RepeatingSectionItem => "repeatingSectionItem",
}
}
/// The list entries of a combo box or drop-down list.
pub fn items(&self) -> Option<&Vec<ListItem>> {
match self {
ControlKind::ComboBox { items, .. } | ControlKind::DropDown { items, .. } => Some(items),
_ => None,
}
}
/// Typed text may go in (not a check box, drop-down list or picture).
pub fn takes_typing(&self) -> bool {
!matches!(self, ControlKind::CheckBox { .. } | ControlKind::DropDown { .. } | ControlKind::Picture)
}
/// The text its placeholder shows by default.
pub fn default_placeholder(&self) -> &'static str {
match self {
ControlKind::ComboBox { .. } | ControlKind::DropDown { .. } => "Pick an option.",
ControlKind::Date { .. } => "Pick a date.",
ControlKind::CheckBox { .. } | ControlKind::Picture | ControlKind::RepeatingSection { .. } | ControlKind::RepeatingSectionItem => "",
_ => "Type here.",
}
}
}
/// A content control's properties (`w:sdtPr`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct ContentControl {
/// `w:id` (unique in the document).
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<i64>,
/// `w:tag`: a name for programs.
pub tag: String,
/// `w:alias`: the title shown on the control.
pub title: String,
pub lock: ControlLock,
/// The building block holding the placeholder text (`w:placeholder/w:docPart`).
pub placeholder: String,
/// The content is the placeholder text (`w:showingPlcHdr`).
pub showing_placeholder: bool,
/// The control goes away once its content is edited (`w:temporary`).
pub temporary: bool,
pub kind: ControlKind,
/// The text shown while the control is empty.
pub placeholder_text: String,
/// Around whole paragraphs rather than inside one.
pub block: bool,
/// The run properties of `w:sdtPr` (`w:rPr`), kept verbatim (XML).
#[serde(skip_serializing_if = "String::is_empty")]
pub rpr_xml: String,
/// The same run properties, read: the formatting text typed in place of the placeholder
/// takes (ECMA-376 §17.5.2.27). `None` when the control names none.
#[serde(skip_serializing_if = "Option::is_none")]
pub run_props: Option<crate::props::CharProps>,
/// Other `w:sdtPr` children we don't model, kept verbatim (XML) in file order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub extra: Vec<String>,
/// `w:sdtEndPr`, kept verbatim (XML).
#[serde(skip_serializing_if = "String::is_empty")]
pub end_pr_xml: String,
}
impl ContentControl {
pub fn new(kind: ControlKind) -> ContentControl {
let placeholder_text = kind.default_placeholder().to_string();
ContentControl { kind, placeholder_text, ..Default::default() }
}
/// The text to show while empty.
pub fn placeholder_shown(&self) -> &str {
if self.placeholder_text.is_empty() { self.kind.default_placeholder() } else { &self.placeholder_text }
}
}
/// Content controls around a table, row or cell (see the module docs): the controls that open
/// just before it, outermost first, and how many of the open ones close just after it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct ControlWrap {
pub open: Vec<ContentControl>,
pub close: u32,
}
impl ControlWrap {
pub fn is_empty(&self) -> bool {
self.open.is_empty() && self.close == 0
}
}
/// A control boundary in a block list (not looking into table cells).
#[derive(Clone, Debug, PartialEq)]
pub enum ControlEvent<'a> {
/// A control opens: at a `ControlStart` at byte `off` of paragraph `block`, or (`off`
/// `None`) before table `block`.
Open { block: usize, off: Option<usize>, control: &'a ContentControl },
/// The innermost open control closes: at a `ControlEnd` at `off`, or after table `block`.
Close { block: usize, off: Option<usize> },
}
impl ControlEvent<'_> {
pub fn block(&self) -> usize {
match self {
ControlEvent::Open { block, .. } | ControlEvent::Close { block, .. } => *block,
}
}
pub fn off(&self) -> Option<usize> {
match self {
ControlEvent::Open { off, .. } | ControlEvent::Close { off, .. } => *off,
}
}
}
/// The control boundaries of a block list, in order.
pub fn control_events(bl: &Blocks) -> Vec<ControlEvent<'_>> {
let mut out = Vec::new();
for (i, b) in bl.iter().enumerate() {
match &**b {
Block::Para(p) => {
if !p.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
continue;
}
for (off, o) in p.objects_with_offsets() {
match o {
InlineObject::ControlStart { control } => out.push(ControlEvent::Open { block: i, off: Some(off), control }),
InlineObject::ControlEnd => out.push(ControlEvent::Close { block: i, off: Some(off) }),
_ => {}
}
}
}
Block::Table(t) => {
for c in &t.controls.open {
out.push(ControlEvent::Open { block: i, off: None, control: c });
}
for _ in 0..t.controls.close.min(10_000) {
out.push(ControlEvent::Close { block: i, off: None });
}
}
}
}
out
}
/// Pair events like brackets: `(open index, close index, depth)` for each whole control (in the
/// order they close), and the indexes of events without a partner.
pub fn pair_events(events: &[ControlEvent]) -> (Vec<(usize, usize, usize)>, Vec<usize>) {
let mut open: Vec<usize> = Vec::new();
let (mut pairs, mut orphans) = (Vec::new(), Vec::new());
for (i, e) in events.iter().enumerate() {
match e {
ControlEvent::Open { .. } => open.push(i),
ControlEvent::Close { .. } => match open.pop() {
Some(o) => pairs.push((o, i, open.len())),
None => orphans.push(i),
},
}
}
orphans.extend(open);
orphans.sort_unstable();
(pairs, orphans)
}
/// A whole content control around paragraph content.
#[derive(Clone, Debug, PartialEq)]
pub struct ControlRange {
pub control: ContentControl,
/// Where the `ControlStart` marker is.
pub start: Pos,
/// Where the `ControlEnd` marker is (the content is everything between the two).
pub end: Pos,
/// Number of enclosing controls in the same block list.
pub depth: usize,
}
impl ControlRange {
/// The start of the content (just after the start marker).
pub fn content_start(&self) -> Pos {
Pos { off: self.start.off + OBJ.len_utf8(), ..self.start.clone() }
}
/// Whether `p` is inside the control (between its markers, either edge included).
pub fn contains(&self, p: &Pos) -> bool {
p.story == self.start.story
&& (&p.path, p.off) >= (&self.start.path, self.start.off + OBJ.len_utf8())
&& (&p.path, p.off) <= (&self.end.path, self.end.off)
}
/// Whether the content is empty (nothing but other controls' markers would be content too).
pub fn is_empty(&self) -> bool {
self.start.path == self.end.path && self.end.off == self.start.off + OBJ.len_utf8()
}
}
fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<ControlRange>) {
if depth > MAX_DEPTH {
return;
}
let events = control_events(bl);
if !events.is_empty() {
let (pairs, _) = pair_events(&events);
for (o, c, d) in pairs {
let (Some(ControlEvent::Open { block: ob, off: Some(oo), control }), Some(ControlEvent::Close { block: cb, off: Some(co) })) =
(events.get(o), events.get(c))
else {
continue;
};
let at = |b: usize, off: usize| {
let mut path = prefix.clone();
path.push(u32::try_from(b).unwrap_or(u32::MAX));
Pos::new(story, Path(path), off)
};
out.push(ControlRange { control: (*control).clone(), start: at(*ob, *oo), end: at(*cb, *co), depth: d });
}
}
for (i, b) in bl.iter().enumerate() {
if let Block::Table(t) = &**b {
prefix.push(u32::try_from(i).unwrap_or(u32::MAX));
for (r, row) in t.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate() {
prefix.push(u32::try_from(r).unwrap_or(u32::MAX));
prefix.push(u32::try_from(c).unwrap_or(u32::MAX));
scan(story, &cell.blocks, prefix, depth + 1, out);
prefix.pop();
prefix.pop();
}
}
prefix.pop();
}
}
}
/// Positions of the markers without a partner in a block list and the cells inside it.
fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
if depth > MAX_DEPTH {
return;
}
let events = control_events(bl);
let (_, lone) = pair_events(&events);
for k in lone {
let Some(e) = events.get(k) else { continue };
let mut path = prefix.clone();
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
match (e, e.off()) {
(_, Some(off)) => out.push((Path(path), off)),
(ControlEvent::Open { .. }, None) => {
// Which of the table's opens: count those before it on the same table.
let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count();
tables.push((Path(path), nth, true));
}
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
}
}
for (i, b) in bl.iter().enumerate() {
if let Block::Table(t) = &**b {
prefix.push(u32::try_from(i).unwrap_or(u32::MAX));
for (r, row) in t.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate() {
prefix.push(u32::try_from(r).unwrap_or(u32::MAX));
prefix.push(u32::try_from(c).unwrap_or(u32::MAX));
orphans(&cell.blocks, prefix, depth + 1, out, tables);
prefix.pop();
prefix.pop();
}
}
prefix.pop();
}
}
}
/// Remove the markers without a partner from a list of blocks (a copied fragment).
pub fn balance_blocks(blocks: &mut [Block]) {
let mut bl: Blocks = blocks.iter().cloned().map(std::sync::Arc::new).collect();
if balance_list(&mut bl) > 0 {
for (dst, src) in blocks.iter_mut().zip(bl) {
*dst = std::sync::Arc::try_unwrap(src).unwrap_or_else(|a| (*a).clone());
}
}
}
/// Remove orphan markers (and table wraps) from a block list; returns how many went.
fn balance_list(bl: &mut Blocks) -> usize {
let (mut paras, mut tables) = (Vec::new(), Vec::new());
orphans(bl, &mut Vec::new(), 0, &mut paras, &mut tables);
if paras.is_empty() && tables.is_empty() {
return 0;
}
let mut d = Document { body: std::mem::take(bl), ..Document::new() };
let removed = d.remove_orphans(StoryRef::Body, paras, tables);
*bl = d.body;
removed
}
impl Document {
fn control_stories(&self) -> Vec<StoryRef> {
std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k))).collect()
}
/// The whole content controls of a story (around paragraph content), in the order they close.
pub fn control_ranges(&self, story: StoryRef) -> Vec<ControlRange> {
let mut out = Vec::new();
if let Some(bl) = self.story(story) {
scan(story, bl, &mut Vec::new(), 0, &mut out);
}
out
}
/// How many content controls allow form filling (`w:lock` content editing) across every
/// story; 0 means a `w:edit="forms"` document has nothing left editable.
pub fn fillable_controls(&self) -> usize {
self.control_stories().into_iter().map(|story| self.control_ranges(story).iter().filter(|r| r.control.lock.can_edit()).count()).sum()
}
/// The innermost content control holding `p`.
pub fn control_at(&self, p: &Pos) -> Option<ControlRange> {
let para = self.para_at(p)?;
// Cheap test first: no controls anywhere near.
if !para.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd))
&& !self.story_has_controls(p.story)
{
return None;
}
self.control_ranges(p.story)
.into_iter()
.filter(|r| r.contains(p))
.max_by(|a, b| (&a.start.path, a.start.off).cmp(&(&b.start.path, b.start.off)))
}
fn story_has_controls(&self, s: StoryRef) -> bool {
let Some(bl) = self.story(s) else { return false };
let mut any = false;
for b in bl {
crate::edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. })));
if any {
return true;
}
}
false
}
/// The control whose start marker is at `start`, to change.
pub fn control_mut(&mut self, start: &Pos) -> Option<&mut ContentControl> {
let p = self.para_mut(start.story, &start.path).ok()?;
p.touch();
match p.object_at_mut(start.off)? {
InlineObject::ControlStart { control } => Some(control),
_ => None,
}
}
/// The content of a control as plain text (paragraphs joined by `\n`).
pub fn control_text(&self, r: &ControlRange) -> String {
let a = r.content_start();
let mut out = String::new();
for path in self.paths_between(&a, &r.end) {
let Some(p) = self.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == r.end.path { r.end.off } else { p.len() };
if !out.is_empty() {
out.push('\n');
}
let mut k = p.text.get(..from).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0);
for c in p.text.get(from..to.max(from)).unwrap_or("").chars() {
if c == OBJ {
if let Some(o) = p.objects.get(k) {
out.push_str(o.plain_text());
}
k += 1;
} else {
out.push(c);
}
}
}
out
}
/// Whether any story holds a control marker without its partner.
pub fn has_unbalanced_controls(&self) -> bool {
self.control_stories().into_iter().any(|s| {
self.story(s).is_some_and(|bl| {
let (mut p, mut t) = (Vec::new(), Vec::new());
orphans(bl, &mut Vec::new(), 0, &mut p, &mut t);
!p.is_empty() || !t.is_empty()
})
})
}
/// Remove control markers (and table wraps' opens and closes) that have no partner. Returns
/// how many were removed. (Row and cell wraps are balanced as they are written.)
pub fn balance_controls(&mut self) -> usize {
let mut removed = 0;
for s in self.control_stories() {
let Some(bl) = self.story(s) else { continue };
let (mut paras, mut tables) = (Vec::new(), Vec::new());
orphans(bl, &mut Vec::new(), 0, &mut paras, &mut tables);
removed += self.remove_orphans(s, paras, tables);
}
removed
}
fn remove_orphans(&mut self, s: StoryRef, mut paras: Vec<(Path, usize)>, tables: Vec<(Path, usize, bool)>) -> usize {
let mut removed = 0;
// Last first, so earlier offsets in the same paragraph stay valid.
paras.sort();
for (path, off) in paras.into_iter().rev() {
if let Ok(p) = self.para_mut(s, &path)
&& p.delete(off, off + OBJ.len_utf8()).is_ok()
{
removed += 1;
}
}
// Table opens: highest index first per table.
let mut tables = tables;
tables.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
for (path, nth, open) in tables {
if let Ok(t) = self.table_mut(s, &path) {
if open {
if nth < t.controls.open.len() {
t.controls.open.remove(nth);
removed += 1;
}
} else if t.controls.close > 0 {
t.controls.close -= 1;
removed += 1;
}
}
}
removed
}
/// The control markers in `a..b` whose partner is outside it (a deletion keeps them).
pub(crate) fn kept_control_markers(&self, a: &Pos, b: &Pos) -> Vec<(Path, usize)> {
let mut out = Vec::new();
let paths = self.paths_between(a, b);
let any = paths.iter().any(|p| {
self.para(a.story, p).is_some_and(|q| q.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)))
});
if !any {
return out;
}
let inside = |p: &Path, off: usize| (p, off) >= (&a.path, a.off) && (p, off + OBJ.len_utf8()) <= (&b.path, b.off);
// Ranges whose markers are in paragraphs.
let ranges = self.control_ranges(a.story);
for r in &ranges {
let (s, e) = (inside(&r.start.path, r.start.off), inside(&r.end.path, r.end.off));
if s && !e {
out.push((r.start.path.clone(), r.start.off));
}
if e && !s {
out.push((r.end.path.clone(), r.end.off));
}
}
// Markers paired with a table wrap: their partner is never deleted with them. Each lookup
// is a set probe, and each container is paired once, not once per marker (#3614).
let in_ranges: BTreeSet<(&Path, usize)> = ranges.iter().flat_map(|r| [(&r.start.path, r.start.off), (&r.end.path, r.end.off)]).collect();
let mut table_paired: BTreeMap<&[u32], BTreeSet<(usize, usize)>> = BTreeMap::new();
for path in &paths {
let Some(p) = self.para(a.story, path) else { continue };
for (off, o) in p.objects_with_offsets() {
if !inside(path, off)
|| !matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)
|| in_ranges.contains(&(path, off))
{
continue;
}
let paired = table_paired.entry(path.parent()).or_insert_with(|| self.table_paired_markers(a.story, path));
if paired.contains(&(path.last(), off)) {
out.push((path.clone(), off));
}
}
}
// Sorted and without repeats: `delete_range` groups this by paragraph and binary-searches
// each group (`control_markers_kept_are_sorted` pins it).
out.sort();
out.dedup();
out
}
/// (block, offset) of the paragraph markers in `path`'s container that are paired with a
/// table wrap's open or close.
fn table_paired_markers(&self, s: StoryRef, path: &Path) -> BTreeSet<(usize, usize)> {
let Some(bl) = self.container(s, path) else { return BTreeSet::new() };
let events = control_events(bl);
let (pairs, _) = pair_events(&events);
let mut out = BTreeSet::new();
for (o, c, _) in pairs {
let (Some(open), Some(close)) = (events.get(o), events.get(c)) else { continue };
match (open.off(), close.off()) {
(Some(off), None) => {
out.insert((open.block(), off));
}
(None, Some(off)) => {
out.insert((close.block(), off));
}
_ => {}
}
}
out
}
/// Where typing at `p` should go: a caret at the start of a paragraph, before the start of
/// a block-level control, goes inside it; one at the end, after a block-level control's
/// end, goes inside it too (the control is around the whole paragraph).
pub fn typing_pos(&self, p: &Pos) -> Pos {
let Some(para) = self.para_at(p) else { return p.clone() };
// The objects once, looked up by offset (object_at per step rescans the text).
let objs: Vec<(usize, &InlineObject)> = para.objects_with_offsets().collect();
let obj = |off: usize| objs.binary_search_by_key(&off, |(o, _)| *o).ok().and_then(|k| objs.get(k)).map(|(_, o)| *o);
let is_start = |off: usize| matches!(obj(off), Some(InlineObject::ControlStart { control }) if control.block);
let is_end = |off: usize| matches!(obj(off), Some(InlineObject::ControlEnd));
let w = OBJ.len_utf8();
let mut off = p.off;
// Only block starts before the caret (or none) and block starts right after it.
let before_ok = (0..off).step_by(w).all(&is_start) && off.is_multiple_of(w);
if before_ok && is_start(off) {
while is_start(off) {
off += w;
}
return Pos { off, ..p.clone() };
}
if p.off == para.len() && off >= w && is_end(off - w) {
// Step back over the trailing ends that close block-level controls.
let block_ends: BTreeSet<usize> =
self.control_ranges(p.story).into_iter().filter(|r| r.end.path == p.path && r.control.block).map(|r| r.end.off).collect();
while off >= w && is_end(off - w) && block_ends.contains(&(off - w)) {
off -= w;
}
return Pos { off, ..p.clone() };
}
p.clone()
}
}
/// Every control in `bl` (and its tables), for walks that need them all.
pub fn walk_controls(bl: &Blocks, f: &mut dyn FnMut(&ContentControl)) {
fn go(bl: &Blocks, f: &mut dyn FnMut(&ContentControl), depth: usize) {
if depth > MAX_DEPTH {
return;
}
for b in bl {
match &**b {
Block::Para(p) => {
for o in &p.objects {
if let InlineObject::ControlStart { control } = o {
f(control);
}
}
}
Block::Table(t) => {
t.controls.open.iter().for_each(&mut *f);
for r in &t.rows {
r.controls.open.iter().for_each(&mut *f);
for c in &r.cells {
c.controls.open.iter().for_each(&mut *f);
go(&c.blocks, f, depth + 1);
}
}
}
}
}
}
go(bl, f, 0);
}
+54 -1049
View File
File diff suppressed because it is too large Load Diff
+2 -8
View File
@@ -33,10 +33,6 @@ pub struct ShapeEffects {
/// An outer shadow: a blurred copy of the shape's silhouette cast `distance` points away in
/// direction `angle` (degrees clockwise from the positive x axis, y pointing down).
///
/// `rot_with_shape` (DrawingML `rotWithShape`, true when absent) says whether that direction is
/// the shape's own, turning with it when it is rotated or flipped, or the page's. Word's shadow
/// presets write `rotWithShape="0"`: the light stays put as the shape turns.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Shadow {
@@ -45,13 +41,12 @@ pub struct Shadow {
pub blur: f32,
pub distance: f32,
pub angle: f32,
pub rot_with_shape: bool,
}
impl Default for Shadow {
/// "Offset: Bottom Right".
fn default() -> Self {
Shadow { color: Rgb::BLACK, transparency: 60.0, blur: 4.0, distance: 3.0, angle: 45.0, rot_with_shape: false }
Shadow { color: Rgb::BLACK, transparency: 60.0, blur: 4.0, distance: 3.0, angle: 45.0 }
}
}
@@ -114,7 +109,6 @@ impl Shadow {
blur: clamp(self.blur, 0.0, MAX_BLUR, 0.0),
distance: clamp(self.distance, 0.0, MAX_DISTANCE, 0.0),
angle: if angle.is_finite() && angle < 360.0 { angle } else { 0.0 },
rot_with_shape: self.rot_with_shape,
}
}
/// The shadow's offset from the shape, points (x right, y down).
@@ -217,7 +211,7 @@ mod tests {
#[test]
fn hostile_numbers_are_clamped() {
let s = Shadow { color: Rgb::BLACK, transparency: f32::NAN, blur: 1e9, distance: -4.0, angle: -90.0, rot_with_shape: false }.sanitized();
let s = Shadow { color: Rgb::BLACK, transparency: f32::NAN, blur: 1e9, distance: -4.0, angle: -90.0 }.sanitized();
assert_eq!((s.transparency, s.blur, s.distance, s.angle), (60.0, MAX_BLUR, 0.0, 270.0));
let e = ShapeEffects { shadow: None, glow: Some(Glow { size: 1e9, ..Glow::default() }), soft_edge: Some(-1.0) }.sanitized();
assert_eq!(e.glow.map(|g| g.size), Some(MAX_GLOW));
+1 -412
View File
@@ -1,8 +1,4 @@
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), Mac OS Roman
//! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC,
//! 950 Big5), whose bytes decode as runs rather than one at a time.
//! Windows-1252 (the byte encoding legacy Word text pieces and RTF bytes use).
/// Windows-1252 code points for 0x80..=0x9F.
const CP1252: [u16; 32] = [
@@ -18,410 +14,3 @@ pub fn cp1252(b: u8) -> char {
b as char
}
}
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP874: [u16; 128] = [
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
0xFFFD, 0xFFFD,
];
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1250: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
0x0163, 0x02D9,
];
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1251: [u16; 128] = [
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429,
0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B,
0x043C, 0x043D, 0x043E, 0x043F, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
0x044E, 0x044F,
];
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1253: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
0x03CE, 0xFFFD,
];
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1254: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
0x015F, 0x00FF,
];
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1255: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
0x05B8, 0x05B9, 0x05BA, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
0x200F, 0xFFFD,
];
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1256: [u16; 128] = [
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
0x200F, 0x06D2,
];
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1257: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
0x017E, 0x02D9,
];
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1258: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
0x20AB, 0x00FF,
];
/// Mac OS Roman (code page 10000, RTF `\mac`) code points for 0x80..=0xFF, with the euro sign at
/// 0xDB (Mac OS 8.5 and later).
const MAC_ROMAN: [u16; 128] = [
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB,
0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5,
0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178,
0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD,
0x02DB, 0x02C7,
];
/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437
/// mapping file).
const CP437: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4,
0x221E, 0x03C6, 0x03B5, 0x2229, 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 850 (DOS Latin-1, Western European) code points for 0x80..=0xFF (the Unicode
/// Consortium's CP850 mapping file).
const CP850: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9,
0x00FD, 0x00DD, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 852 (DOS Latin-2, Central European) code points for 0x80..=0xFF (the Unicode Consortium's CP852
/// mapping file).
const CP852: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, 0x00C9, 0x0139,
0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518,
0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170,
0x00FD, 0x00DD, 0x0163, 0x00B4, 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159,
0x25A0, 0x00A0,
];
/// OEM code page 866 (DOS Cyrillic) code points for 0x80..=0xFF (the Unicode Consortium's CP866
/// mapping file).
const CP866: [u16; 128] = [
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421,
0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433,
0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B,
0x044C, 0x044D, 0x044E, 0x044F, 0x0401, 0x0451, 0x0404, 0x0454, 0x0407, 0x0457, 0x040E, 0x045E, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x2116, 0x00A4,
0x25A0, 0x00A0,
];
/// OEM code page 737 (DOS Greek) code points for 0x80..=0xFF (the Unicode Consortium's CP737
/// mapping file).
const CP737: [u16; 128] = [
0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0x03A3,
0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC,
0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388,
0x0389, 0x038A, 0x038C, 0x038E, 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 857 (DOS Turkish; U+FFFD where the byte is unassigned) code points for 0x80..=0xFF (the Unicode Consortium's CP857
/// mapping file).
const CP857: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9,
0x00EC, 0x00FF, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// The glyphs the OEM code pages show for 0x01..=0x1F (smileys, card suits, arrows), as
/// Windows' Alt+numpad entry produces them rather than the control characters.
const OEM_GLYPHS: [u16; 31] = [
0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, 0x25BA, 0x25C4, 0x2195,
0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, 0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
];
/// The OEM code pages [`oem_byte`] decodes.
pub const OEM_CODE_PAGES: [u16; 6] = [437, 737, 850, 852, 857, 866];
/// One byte of an OEM code page in [`OEM_CODE_PAGES`] (any other code page reads as 437) as a
/// char, with the graphic glyphs for 0x01..=0x1F and 0x7F (⌂); 0x00 stays NUL.
pub fn oem_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
737 => &CP737,
850 => &CP850,
852 => &CP852,
857 => &CP857,
866 => &CP866,
_ => &CP437,
};
let u = match b {
0x01..=0x1F => OEM_GLYPHS.get(usize::from(b - 1)).copied(),
0x7F => Some(0x2302),
0x80..=0xFF => table.get(usize::from(b - 0x80)).copied(),
_ => Some(u16::from(b)),
};
u.and_then(|u| char::from_u32(u32::from(u))).unwrap_or('\u{FFFD}')
}
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
/// Mac OS Roman, RTF's `\mac` character set.
pub const MAC_ROMAN_CODE_PAGE: u16 = 10000;
/// One byte of a single-byte code page as a char: a Windows one in [`SINGLE_BYTE_CODE_PAGES`] or
/// Mac OS Roman ([`MAC_ROMAN_CODE_PAGE`]); any other code page reads as Windows-1252.
pub fn windows_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
874 => &CP874,
1250 => &CP1250,
1251 => &CP1251,
1253 => &CP1253,
1254 => &CP1254,
1255 => &CP1255,
1256 => &CP1256,
1257 => &CP1257,
1258 => &CP1258,
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
_ => return cp1252(b),
};
if b < 0x80 {
return b as char;
}
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
}
/// Whether [`code_page_byte`] decodes `code_page`: a Windows one ([`SINGLE_BYTE_CODE_PAGES`]), an
/// OEM (DOS) one ([`OEM_CODE_PAGES`]) or Mac OS Roman. Document formats (RTF) name all three.
pub fn is_byte_code_page(code_page: u16) -> bool {
SINGLE_BYTE_CODE_PAGES.contains(&code_page) || OEM_CODE_PAGES.contains(&code_page) || code_page == MAC_ROMAN_CODE_PAGE
}
/// A byte of 8-bit document text in `code_page` (see [`is_byte_code_page`]) as a char. Below 0x80
/// it is ASCII in every code page (text, so an OEM page's glyphs for control codes don't apply).
pub fn code_page_byte(code_page: u16, b: u8) -> char {
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
}
/// The East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`:
/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5
/// (Traditional Chinese).
pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950];
/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]):
/// its bytes decode as a run, a lead byte together with its trail byte.
pub fn is_multibyte_code_page(code_page: u16) -> bool {
multibyte_encoding_name(code_page).is_some()
}
/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]),
/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes
/// decode one at a time, and anything else.
pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> {
Some(match code_page {
932 => "shift_jis",
936 => "gbk",
949 => "euc-kr",
950 => "big5",
_ => return None,
})
}
/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac
/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]).
pub fn is_text_code_page(code_page: u16) -> bool {
is_byte_code_page(code_page) || is_multibyte_code_page(code_page)
}
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949,
/// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default
/// charsets (the document's code page applies) and symbol fonts.
pub fn charset_code_page(charset: i32) -> Option<u16> {
Some(match charset {
77 => MAC_ROMAN_CODE_PAGE,
128 => 932,
129 => 949,
134 => 936,
136 => 950,
161 => 1253,
162 => 1254,
163 => 1258,
177 => 1255,
178 => 1256,
186 => 1257,
204 => 1251,
222 => 874,
238 => 1250,
254 | 255 => 437,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_byte_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| windows_byte(cp, b)).collect::<String>();
assert_eq!(decode(1251, &[0xD1, 0xF7, 0xE5, 0xF2, 0x20, 0xB9]), "Счет №");
assert_eq!(decode(1251, &[0xA8, 0xB8]), "Ёё");
assert_eq!(decode(1250, &[0xA3, 0xF3, 0x9F]), "Łóź");
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
assert_eq!(decode(9999, &[0xE9]), "é");
// Mac OS Roman (#1653): accents, the euro sign at 0xDB, typographic quotes.
assert_eq!(decode(10000, &[0x80, 0x8E, 0xDB, 0xF1, 0xF2, 0xF3, 0xD2, 0xD3, 0x41]), "Äé€ÒÚÛ“”A");
assert_eq!(charset_code_page(77), Some(10000));
assert_eq!(charset_code_page(254), Some(437));
assert_eq!(charset_code_page(255), Some(437));
assert_eq!(charset_code_page(204), Some(1251));
assert_eq!(charset_code_page(0), None);
for cp in SINGLE_BYTE_CODE_PAGES {
for b in 0..=255u8 {
let _ = windows_byte(cp, b);
}
}
}
#[test]
fn windows_1255_holam_haser_for_vav() {
assert_eq!(windows_byte(1255, 0xCA), '\u{05BA}');
}
/// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac.
#[test]
fn document_text_in_windows_dos_and_mac_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| code_page_byte(cp, b)).collect::<String>();
assert_eq!(decode(437, &[0x82, 0xA0, 0x41, 0x01]), "éáA\u{1}", "DOS text; ASCII stays ASCII");
assert_eq!(decode(850, &[0x9D, 0xE9]), "ØÚ");
assert_eq!(decode(10000, &[0x8E]), "é");
assert_eq!(decode(1251, &[0xC4]), "Д");
for cp in [437, 850, 866, 1250, 1252, 10000] {
assert!(is_byte_code_page(cp), "{cp}");
}
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
}
/// #1653: the East Asian charsets and code pages RTF names are multibyte: their bytes decode
/// as runs (a lead byte and its trail byte together), not one at a time.
#[test]
fn east_asian_code_pages_are_multibyte() {
assert_eq!(charset_code_page(128), Some(932), "Japanese");
assert_eq!(charset_code_page(129), Some(949), "Korean");
assert_eq!(charset_code_page(134), Some(936), "Simplified Chinese");
assert_eq!(charset_code_page(136), Some(950), "Traditional Chinese");
assert_eq!(charset_code_page(255), Some(437), "OEM is single-byte");
assert_eq!(MULTIBYTE_CODE_PAGES, [932, 936, 949, 950]);
for cp in MULTIBYTE_CODE_PAGES {
assert!(is_multibyte_code_page(cp) && is_text_code_page(cp), "{cp}");
assert!(!is_byte_code_page(cp) && !SINGLE_BYTE_CODE_PAGES.contains(&cp), "{cp}");
assert!(multibyte_encoding_name(cp).is_some(), "{cp}");
}
assert!(is_text_code_page(1251) && is_text_code_page(437) && is_text_code_page(10000));
assert!(!is_text_code_page(0) && !is_text_code_page(9999));
}
#[test]
fn oem_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
assert_eq!(decode(437, &[0x82, 0xE9, 0x01, 0x7F]), "éΘ☺⌂");
assert_eq!(decode(850, &[0x9B, 0xE9]), "øÚ");
assert_eq!(decode(852, &[0xA5, 0x88, 0xBE, 0xA9]), "ąłżę");
assert_eq!(decode(866, &[0x8F, 0xE0, 0xA8, 0xA2, 0xA5, 0xE2, 0xF0, 0xFC]), "ПриветЁ№");
assert_eq!(decode(737, &[0x80, 0x99, 0xE0]), "Αβω");
assert_eq!(decode(857, &[0x8D, 0x98, 0xA6, 0x9F]), "ıİĞş");
assert_eq!(oem_byte(857, 0xD5), '\u{FFFD}', "unassigned in 857");
assert_eq!(decode(9999, &[0xE9]), "Θ", "unknown pages read as 437");
for cp in OEM_CODE_PAGES {
for b in 0..=255u8 {
let _ = oem_byte(cp, b);
}
}
}
}
-311
View File
@@ -1,311 +0,0 @@
//! The EQ field's overstrike switch (ECMA-376 Part 1 §17.16.5.20, `\o`): its arguments are drawn
//! on top of one another, centred (`\ac`, the default), left-aligned (`\al`) or right-aligned
//! (`\ar`). Home › Font › Enclose Characters stores a character in a circle, square, triangle or
//! diamond as one: ` eq \o\ac(○,字)`.
//!
//! Which of the two enclosure styles a field has is kept in the document model as a trailing
//! `\* enlarge` (WordCraft's own; nothing is written for the default "shrink text" style). In a
//! `.docx` the style is Word's: the font sizes of the field code's runs (the text smaller than the
//! symbol, or the symbol larger than the text), so the marker never reaches a file.
/// How the overstruck arguments line up.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OAlign {
Left,
#[default]
Center,
Right,
}
/// An `EQ \o(…)` field: the arguments' text (nested switches flattened to their text).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Overstrike {
pub align: OAlign,
pub args: Vec<String>,
}
/// Most arguments read from one field (the rest are ignored).
const MAX_ARGS: usize = 16;
/// Parse an `EQ \o` field code; `None` for any other field (or EQ switch).
pub fn parse_overstrike(instr: &str) -> Option<Overstrike> {
let t = instr.trim_start();
let name_end = t.find(|c: char| c.is_whitespace() || c == '\\').unwrap_or(t.len());
if !t.get(..name_end)?.eq_ignore_ascii_case("eq") {
return None;
}
let mut rest = t.get(name_end..)?.trim_start();
// `\o`, then its alignment options, then the argument list.
rest = rest.strip_prefix('\\')?;
let o = rest.chars().next()?;
if !o.eq_ignore_ascii_case(&'o') {
return None;
}
rest = rest.get(1..)?;
let mut align = OAlign::Center;
loop {
rest = rest.trim_start();
if let Some(r) = rest.strip_prefix('(') {
rest = r;
break;
}
let r = rest.strip_prefix('\\')?;
let sw = r.get(..2)?;
align = match sw.to_ascii_lowercase().as_str() {
"al" => OAlign::Left,
"ac" => OAlign::Center,
"ar" => OAlign::Right,
_ => return None,
};
rest = r.get(2..)?;
}
// Arguments up to the matching `)`, split at top-level `,` or `;` (the list separator).
let mut args = Vec::new();
let mut cur = String::new();
let mut depth = 0usize;
let mut chars = rest.chars();
let mut closed = false;
while let Some(c) = chars.next() {
match c {
'\\' => {
cur.push(c);
if let Some(n) = chars.next() {
cur.push(n);
}
}
'(' => {
depth = depth.saturating_add(1);
cur.push(c);
}
')' if depth == 0 => {
closed = true;
break;
}
')' => {
depth -= 1;
cur.push(c);
}
',' | ';' if depth == 0 => {
if args.len() < MAX_ARGS {
args.push(arg_text(&cur));
}
cur.clear();
}
c => cur.push(c),
}
}
if !closed {
return None;
}
if args.len() < MAX_ARGS {
args.push(arg_text(&cur));
}
Some(Overstrike { align, args })
}
/// An argument's text: escapes (`\,` `\(` `\)` `\\`) become the character, nested switches
/// (`\s\up4(…)`) and their brackets are dropped, leaving what they show.
fn arg_text(s: &str) -> String {
let mut out = String::new();
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
match c {
'\\' => match it.peek().copied() {
Some(n @ (',' | '(' | ')' | '\\' | ';')) => {
out.push(n);
it.next();
}
// A switch: its letters, then an optional (signed) number.
Some(_) => {
while it.next_if(char::is_ascii_alphabetic).is_some() {}
while it.next_if(|n| *n == ' ').is_some() {}
while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {}
}
None => {}
},
'(' | ')' => {}
c => out.push(c),
}
}
out
}
/// Escape text for an EQ argument.
fn escape_arg(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, ',' | '(' | ')' | '\\' | ';') {
out.push('\\');
}
out.push(c);
}
out
}
/// The enclosure drawn around enclosed characters.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum EncloseShape {
Circle,
Square,
Triangle,
Diamond,
}
impl EncloseShape {
pub const ALL: [EncloseShape; 4] = [EncloseShape::Circle, EncloseShape::Square, EncloseShape::Triangle, EncloseShape::Diamond];
/// The symbol in the field code.
pub fn symbol(self) -> char {
match self {
EncloseShape::Circle => '○',
EncloseShape::Square => '□',
EncloseShape::Triangle => '△',
EncloseShape::Diamond => '◇',
}
}
/// The shape a field-code symbol stands for (the usual symbols and their large or small
/// variants).
pub fn from_symbol(s: &str) -> Option<EncloseShape> {
let mut it = s.trim().chars();
let c = it.next()?;
if it.next().is_some() {
return None;
}
Some(match c {
'○' | '◯' | '⭘' | '◦' | '〇' => EncloseShape::Circle,
'□' | '◻' | '⬜' | '▢' | '◽' => EncloseShape::Square,
'△' | '▵' | '⊿' => EncloseShape::Triangle,
'◇' | '◊' | '⬦' | '⋄' => EncloseShape::Diamond,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
EncloseShape::Circle => "circle",
EncloseShape::Square => "square",
EncloseShape::Triangle => "triangle",
EncloseShape::Diamond => "diamond",
}
}
pub fn parse(s: &str) -> Option<EncloseShape> {
EncloseShape::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s.trim()))
}
}
/// Shrink the text to fit the shape, or enlarge the shape around the text.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum EncloseStyle {
#[default]
Shrink,
Enlarge,
}
impl EncloseStyle {
pub fn name(self) -> &'static str {
match self {
EncloseStyle::Shrink => "shrink",
EncloseStyle::Enlarge => "enlarge",
}
}
pub fn parse(s: &str) -> Option<EncloseStyle> {
match s.trim().to_ascii_lowercase().as_str() {
"shrink" => Some(EncloseStyle::Shrink),
"enlarge" => Some(EncloseStyle::Enlarge),
_ => None,
}
}
}
/// Enclosed characters: an `EQ \o` field with an enclosure symbol and the text.
#[derive(Clone, Debug, PartialEq)]
pub struct Enclosure {
pub shape: EncloseShape,
pub style: EncloseStyle,
pub text: String,
}
/// The model's marker for the "enlarge symbol" style (see the module docs).
const ENLARGE: &str = "\\* enlarge";
impl Enclosure {
/// Enclosed characters from a field code: `\o` with exactly two arguments, one of them an
/// enclosure symbol and the other non-empty text.
pub fn parse(instr: &str) -> Option<Enclosure> {
let o = parse_overstrike(instr)?;
let [a, b] = o.args.as_slice() else { return None };
let (shape, text) = match (EncloseShape::from_symbol(a), EncloseShape::from_symbol(b)) {
(Some(s), _) => (s, b),
(None, Some(s)) => (s, a),
_ => return None,
};
if text.trim().is_empty() {
return None;
}
let close = instr.rfind(')')?;
let tail = instr.get(close + 1..).unwrap_or("").trim();
let style = if tail.eq_ignore_ascii_case(ENLARGE) { EncloseStyle::Enlarge } else { EncloseStyle::Shrink };
Some(Enclosure { shape, style, text: text.clone() })
}
/// The field code in the model (with the style marker; see [`Enclosure::word_instr`]).
pub fn instr(&self) -> String {
match self.style {
EncloseStyle::Shrink => self.word_instr(),
EncloseStyle::Enlarge => format!("{} {ENLARGE}", self.word_instr()),
}
}
/// The field code as Word writes it: `eq \o\ac(○,字)`.
pub fn word_instr(&self) -> String {
format!("eq \\o\\ac({},{})", self.shape.symbol(), escape_arg(&self.text))
}
}
/// Characters Word counts as full width (East Asian wide or fullwidth); two half-width characters
/// or one full-width character fit in an enclosure.
pub fn is_full_width(c: char) -> bool {
matches!(c as u32,
0x1100..=0x115F
| 0x2E80..=0x303E
| 0x3041..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA000..=0xA4CF
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE30..=0xFE4F
| 0xFF00..=0xFF60
| 0xFFE0..=0xFFE6
| 0x20000..=0x3FFFD)
}
/// Whether `text` fits in an enclosure: one full-width character or one or two half-width ones.
pub fn fits_enclosure(text: &str) -> bool {
let n = text.chars().count();
let wide = text.chars().filter(|c| is_full_width(*c)).count();
!text.chars().any(|c| c.is_control() || c == crate::para::OBJ) && (n == 1 || (n == 2 && wide == 0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_word_enclosures_and_general_overstrike() {
let e = Enclosure::parse(" eq \\o\\ac(○,字)").unwrap();
assert_eq!((e.shape, e.style, e.text.as_str()), (EncloseShape::Circle, EncloseStyle::Shrink, "字"));
// Nested switches flatten to their text; the list separator may be `;`.
let e = Enclosure::parse("EQ \\o\\ac(□;\\s\\up 1(12))").unwrap();
assert_eq!((e.shape, e.text.as_str()), (EncloseShape::Square, "12"));
let e = Enclosure { shape: EncloseShape::Diamond, style: EncloseStyle::Enlarge, text: "a,".into() };
assert_eq!(Enclosure::parse(&e.instr()), Some(e.clone()));
assert_eq!(e.word_instr(), "eq \\o\\ac(◇,a\\,)");
let o = parse_overstrike("eq \\o \\al(=,/)").unwrap();
assert_eq!((o.align, o.args), (OAlign::Left, vec!["=".to_string(), "/".to_string()]));
assert!(parse_overstrike("eq \\f(1,2)").is_none());
assert!(parse_overstrike("PAGE").is_none());
assert!(parse_overstrike("eq \\o(unclosed").is_none());
assert!(Enclosure::parse("eq \\o(a,b)").is_none());
assert!(fits_enclosure("字") && fits_enclosure("12") && !fits_enclosure("字字") && !fits_enclosure("123") && !fits_enclosure(""));
}
}
+4 -19
View File
@@ -43,12 +43,12 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
match &**b {
Block::Para(p) => {
let path = Path(prefix.clone());
for (off, o) in p.objects_with_offsets() {
match o {
InlineObject::FieldStart { instr, locked, .. } => {
for off in p.object_offsets() {
match p.object_at(off) {
Some(InlineObject::FieldStart { instr, locked }) => {
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
}
InlineObject::FieldEnd => match open.pop() {
Some(InlineObject::FieldEnd) => match open.pop() {
Some((instr, locked, start)) => {
let depth = open.len();
out.ranges.push(FieldRange { instr, locked, start, end: Pos::new(story, path.clone(), off), depth });
@@ -93,21 +93,6 @@ impl Document {
self.story(story).map(|bl| scan_story(story, bl).ranges).unwrap_or_default()
}
/// `p` moved past the range-field ends right after it. A click just after a field's result
/// lands before its end marker (the marker takes no room); typing or inserting there belongs
/// after the field, as in Word, not in its result.
pub fn past_field_ends(&self, p: &Pos) -> Pos {
let Some(para) = self.para_at(p) else { return p.clone() };
let mut off = p.off;
for (at, o) in para.objects_with_offsets().skip_while(|(at, _)| *at < p.off) {
if at != off || !matches!(o, InlineObject::FieldEnd) {
break;
}
off += OBJ.len_utf8();
}
Pos { off, ..p.clone() }
}
/// Whether any story holds a range-field marker without its partner.
pub fn has_unbalanced_field_ranges(&self) -> bool {
self.story_refs().into_iter().any(|s| self.story(s).is_some_and(|bl| !scan_story(s, bl).orphans.is_empty()))
-237
View File
@@ -1,237 +0,0 @@
//! Shape fills: what paints a shape's, text box's or canvas's interior, and a shape outline's
//! gradient (DrawingML `a:solidFill` / `a:gradFill`, ECMA-376 §20.1.8.54 and §20.1.8.33; VML
//! `v:fill`).
//!
//! A gradient is kept as the file states it (stops, linear angle or path focus, tile rectangle,
//! `rotWithShape`, flip) so saving writes it back; drawing approximates rectangular and
//! shape-following paths as circular. Transparencies are percent (0 = opaque); rectangles are
//! percent insets (left, top, right, bottom) from the shape's edges.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::sync::Arc;
use crate::props::Rgb;
#[cfg(test)]
use crate::wordart::MAX_STOPS;
use crate::wordart::{GradStop, clamp, sanitized_stops};
/// A shape's fill. In `.json` documents an opaque solid fill is written `[r, g, b]` as before
/// fills had transparency or gradients; the rest are tagged (`{"type": "gradient", ...}`).
#[derive(Clone, Debug, PartialEq)]
pub enum Fill {
Solid { color: Rgb, transparency: f32 },
Gradient(Arc<Gradient>),
}
/// A gradient fill.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Gradient {
pub stops: Vec<GradStop>,
pub kind: GradientKind,
/// `a:tileRect`: where the gradient's area sits relative to the shape (percent insets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<[f32; 4]>,
/// Turns with the shape (`rotWithShape`).
#[serde(default)]
pub rot_with_shape: bool,
#[serde(default, skip_serializing_if = "GradientFlip::is_none")]
pub flip: GradientFlip,
}
/// How a gradient runs.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum GradientKind {
/// Along a line at `angle` degrees (0 = left to right, 90 = top to bottom). `scaled`: the
/// angle is scaled with the shape's aspect ratio.
Linear {
angle: f32,
#[serde(default)]
scaled: bool,
},
/// Outwards from `focus` (percent insets from the shape's edges, `a:fillToRect`).
Path { shape: PathShape, focus: [f32; 4] },
}
/// A path gradient's outline (`a:path/@path`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathShape {
Circle,
Rect,
Shape,
}
/// How the gradient's tile is mirrored when tiled (`a:gradFill/@flip`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GradientFlip {
#[default]
None,
X,
Y,
Xy,
}
impl GradientFlip {
pub fn is_none(&self) -> bool {
*self == GradientFlip::None
}
}
/// Largest rectangle inset kept, percent (hostile files).
const MAX_INSET: f32 = 1000.0;
fn rect(r: [f32; 4]) -> [f32; 4] {
r.map(|v| clamp(v, -MAX_INSET, MAX_INSET, 0.0))
}
impl Fill {
/// An opaque solid fill.
pub fn solid(color: Rgb) -> Fill {
Fill::Solid { color, transparency: 0.0 }
}
/// The one colour standing for the fill where only one fits: the solid colour, or a
/// gradient's middle stop.
pub fn main_color(&self) -> Rgb {
match self {
Fill::Solid { color, .. } => *color,
Fill::Gradient(g) => g.stops.get(g.stops.len() / 2).map_or(Rgb::BLACK, |s| s.color),
}
}
/// Finite, in range: at most [`MAX_STOPS`] stops, in order, positions and transparencies
/// 0–100%, angles 0–360°. A gradient with one stop is that stop's solid colour; with none,
/// no fill (`None`).
pub fn sanitized(&self) -> Option<Fill> {
Some(match self {
Fill::Solid { color, transparency } => Fill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
Fill::Gradient(g) => {
let stops = sanitized_stops(&g.stops);
match stops[..] {
[] => return None,
[s] => return Some(Fill::Solid { color: s.color, transparency: s.transparency }),
_ => {}
}
let kind = match g.kind {
GradientKind::Linear { angle, scaled } => {
GradientKind::Linear { angle: clamp(angle, -1e6, 1e6, 0.0).rem_euclid(360.0) % 360.0, scaled }
}
GradientKind::Path { shape, focus } => GradientKind::Path { shape, focus: rect(focus) },
};
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: g.tile.map(rect), rot_with_shape: g.rot_with_shape, flip: g.flip }))
}
})
}
}
/// The tagged `.json` form of fills other than opaque solid ones.
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum Tagged {
Solid {
color: Rgb,
#[serde(default)]
transparency: f32,
},
Gradient(Gradient),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum AnyForm {
Plain(Rgb),
Tagged(Tagged),
}
impl Serialize for Fill {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Fill::Solid { color, transparency: 0.0 } => color.serialize(s),
Fill::Solid { color, transparency } => Tagged::Solid { color: *color, transparency: *transparency }.serialize(s),
Fill::Gradient(g) => Tagged::Gradient(Gradient::clone(g)).serialize(s),
}
}
}
impl<'de> Deserialize<'de> for Fill {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Fill, D::Error> {
Ok(match AnyForm::deserialize(d)? {
AnyForm::Plain(color) => Fill::solid(color),
AnyForm::Tagged(Tagged::Solid { color, transparency }) => Fill::Solid { color, transparency },
AnyForm::Tagged(Tagged::Gradient(g)) => Fill::Gradient(Arc::new(g)),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stop(pos: f32, color: Rgb, transparency: f32) -> GradStop {
GradStop { pos, color, transparency }
}
fn grad(stops: Vec<GradStop>, kind: GradientKind) -> Fill {
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
}
#[test]
fn opaque_solid_fills_are_written_as_before_and_old_documents_load() {
let f = Fill::solid(Rgb(1, 2, 3));
assert_eq!(serde_json::to_string(&f).ok().as_deref(), Some("[1,2,3]"));
assert_eq!(serde_json::from_str::<Fill>("[1,2,3]").ok(), Some(f));
}
#[test]
fn transparent_and_gradient_fills_round_trip_tagged() {
let fills = [
Fill::Solid { color: Rgb::WHITE, transparency: 70.0 },
grad(vec![stop(0.0, Rgb(9, 9, 9), 0.0), stop(100.0, Rgb::WHITE, 50.0)], GradientKind::Linear { angle: 90.0, scaled: true }),
Fill::Gradient(Arc::new(Gradient {
stops: vec![stop(0.0, Rgb::BLACK, 0.0), stop(100.0, Rgb::WHITE, 0.0)],
kind: GradientKind::Path { shape: PathShape::Rect, focus: [50.0, 50.0, 50.0, 50.0] },
tile: Some([-10.0, 0.0, 0.0, 0.0]),
rot_with_shape: false,
flip: GradientFlip::Xy,
})),
];
for f in fills {
let json = serde_json::to_string(&f).unwrap_or_default();
assert!(json.contains("\"type\""), "{json}");
assert_eq!(serde_json::from_str::<Fill>(&json).ok(), Some(f), "{json}");
}
}
#[test]
fn hostile_fills_are_sanitized() {
let many: Vec<GradStop> = (0..30).map(|i| stop(100.0 - i as f32, Rgb::BLACK, f32::NAN)).collect();
let Some(Fill::Gradient(g)) = grad(many, GradientKind::Linear { angle: -450.0, scaled: false }).sanitized() else { panic!() };
assert_eq!(g.stops.len(), MAX_STOPS);
assert!(g.stops.windows(2).all(|w| w[0].pos <= w[1].pos) && g.stops.iter().all(|s| s.transparency == 0.0));
assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false });
let path = grad(
vec![stop(150.0, Rgb::BLACK, 200.0), stop(-5.0, Rgb::WHITE, 0.0)],
GradientKind::Path { shape: PathShape::Circle, focus: [f32::NAN, 1e9, -1e9, 5.0] },
);
let Some(Fill::Gradient(g)) = path.sanitized() else { panic!() };
assert_eq!((g.stops[0].pos, g.stops[1].pos, g.stops[1].transparency), (0.0, 100.0, 100.0));
assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Circle, focus: [0.0, MAX_INSET, -MAX_INSET, 5.0] });
assert_eq!(
grad(vec![stop(0.0, Rgb::WHITE, 30.0)], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(),
Some(Fill::Solid { color: Rgb::WHITE, transparency: 30.0 })
);
assert_eq!(grad(vec![], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(), None);
assert_eq!(Fill::Solid { color: Rgb::BLACK, transparency: f32::NAN }.sanitized(), Some(Fill::solid(Rgb::BLACK)));
}
#[test]
fn main_colour_is_the_middle_stop() {
let g = grad(
vec![stop(0.0, Rgb(1, 0, 0), 0.0), stop(50.0, Rgb(2, 0, 0), 0.0), stop(100.0, Rgb(3, 0, 0), 0.0)],
GradientKind::Linear { angle: 0.0, scaled: false },
);
assert_eq!(g.main_color(), Rgb(2, 0, 0));
assert_eq!(Fill::solid(Rgb::WHITE).main_color(), Rgb::WHITE);
}
}
-205
View File
@@ -1,205 +0,0 @@
//! Freeform shapes: the geometry of a shape drawn point by point (DrawingML `a:custGeom`), such as
//! an ink stroke drawn with a pen, pencil or highlighter (Draw tab).
use serde::{Deserialize, Serialize};
/// Most points one path keeps (hostile files and commands): a long pen stroke has a few hundred.
pub const MAX_POINTS: usize = 5_000;
/// Most paths one freeform keeps.
pub const MAX_PATHS: usize = 256;
/// Most ink strokes on one page: drawing more is refused.
pub const MAX_INK_PER_PAGE: usize = 2_000;
/// Largest coordinate kept, in the path's own coordinate space.
const MAX_COORD: f32 = 1.0e6;
/// The pen an ink stroke was drawn with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum InkTool {
#[default]
Pen,
Pencil,
Highlighter,
}
impl InkTool {
pub fn parse(s: &str) -> Option<InkTool> {
match s.to_ascii_lowercase().as_str() {
"pen" => Some(InkTool::Pen),
"pencil" => Some(InkTool::Pencil),
"highlighter" => Some(InkTool::Highlighter),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
InkTool::Pen => "pen",
InkTool::Pencil => "pencil",
InkTool::Highlighter => "highlighter",
}
}
/// The colour a new stroke gets (black pen, grey pencil, yellow highlighter).
pub fn default_color(self) -> crate::props::Rgb {
match self {
InkTool::Pen => crate::props::Rgb(0, 0, 0),
InkTool::Pencil => crate::props::Rgb(0x59, 0x59, 0x59),
InkTool::Highlighter => crate::props::Rgb(0xFF, 0xFF, 0x00),
}
}
/// The width a new stroke gets, points.
pub fn default_width(self) -> f32 {
match self {
InkTool::Pen => 1.5,
InkTool::Pencil => 1.0,
InkTool::Highlighter => 12.0,
}
}
/// How opaque its strokes are: a highlighter's let the text show through, a pencil's are a
/// little lighter than a pen's.
pub fn alpha(self) -> f32 {
match self {
InkTool::Pen => 1.0,
InkTool::Pencil => 0.85,
InkTool::Highlighter => 0.5,
}
}
}
/// One path of a freeform: points joined by straight lines, closed back to the first or not.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FreePath {
pub pts: Vec<[f32; 2]>,
pub closed: bool,
}
/// A freeform shape's geometry: paths in a `w` × `h` coordinate space that is stretched over the
/// shape's frame (DrawingML's `a:path` `w` and `h`). Drawn with the shape's outline colour and
/// width (and its fill, for closed paths that aren't ink).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Freeform {
pub w: f32,
pub h: f32,
pub paths: Vec<FreePath>,
/// Opacity of the outline, 0–1.
pub alpha: f32,
/// Set when this is ink: drawn with a pen (Draw tab), hidden by Review › Hide Ink and removed
/// by the eraser.
#[serde(skip_serializing_if = "Option::is_none")]
pub ink: Option<InkTool>,
/// Ink: its place in the order the strokes were drawn (1, 2, 3…), for Draw › Ink Replay.
/// `None` for ink from files that don't record it (replayed first, in document order).
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<u32>,
}
impl Default for Freeform {
fn default() -> Self {
Freeform { w: 0.0, h: 0.0, paths: Vec::new(), alpha: 1.0, ink: None, order: None }
}
}
impl Freeform {
/// An ink stroke through `pts` (shape-local points, already in the `w` × `h` frame).
pub fn ink(tool: InkTool, w: f32, h: f32, pts: Vec<[f32; 2]>) -> Freeform {
Freeform { w, h, paths: vec![FreePath { pts, closed: false }], alpha: tool.alpha(), ink: Some(tool), order: None }.sanitized()
}
/// The same geometry with non-finite points dropped, coordinates clamped, the counts capped
/// and the opacity in 0–1: safe to draw whatever file or command it came from.
pub fn sanitized(mut self) -> Freeform {
let fin = |v: f32| if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 };
self.w = fin(self.w).max(0.0);
self.h = fin(self.h).max(0.0);
self.alpha = if self.alpha.is_finite() { self.alpha.clamp(0.0, 1.0) } else { 1.0 };
self.paths.truncate(MAX_PATHS);
for p in &mut self.paths {
p.pts.retain(|[x, y]| x.is_finite() && y.is_finite());
p.pts.truncate(MAX_POINTS);
for pt in &mut p.pts {
*pt = pt.map(fin);
}
}
self.paths.retain(|p| !p.pts.is_empty());
self
}
pub fn is_ink(&self) -> bool {
self.ink.is_some()
}
/// The paths mapped onto a frame at (`x`, `y`) of size `fw` × `fh` (page points). A zero
/// coordinate space takes the frame's own size.
pub fn placed(&self, x: f32, y: f32, fw: f32, fh: f32) -> Vec<(Vec<[f32; 2]>, bool)> {
let sx = if self.w > 0.0 { fw / self.w } else { 1.0 };
let sy = if self.h > 0.0 { fh / self.h } else { 1.0 };
let (sx, sy) = (if sx.is_finite() { sx } else { 1.0 }, if sy.is_finite() { sy } else { 1.0 });
self.paths
.iter()
.take(MAX_PATHS)
.map(|p| (p.pts.iter().take(MAX_POINTS).map(|[px, py]| [x + px * sx, y + py * sy]).collect(), p.closed))
.collect()
}
/// Distance from (`px`, `py`) to the nearest path of this freeform placed on a frame at
/// (`x`, `y`), `fw` × `fh` (page points); `None` when it has no points.
pub fn distance(&self, x: f32, y: f32, fw: f32, fh: f32, px: f32, py: f32) -> Option<f32> {
let mut best: Option<f32> = None;
for (pts, closed) in self.placed(x, y, fw, fh) {
let n = pts.len();
let segs = if closed && n > 2 { n } else { n.saturating_sub(1) };
let mut consider = |a: [f32; 2], b: [f32; 2]| {
let d = seg_distance(a, b, [px, py]);
if d.is_finite() && best.is_none_or(|bd| d < bd) {
best = Some(d);
}
};
if n == 1
&& let Some(a) = pts.first()
{
consider(*a, *a);
}
for i in 0..segs {
if let (Some(a), Some(b)) = (pts.get(i), pts.get((i + 1) % n)) {
consider(*a, *b);
}
}
}
best
}
}
/// Distance from `p` to the segment `a`–`b`.
fn seg_distance(a: [f32; 2], b: [f32; 2], p: [f32; 2]) -> f32 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let (cx, cy) = (a[0] + t * dx, a[1] + t * dy);
((p[0] - cx).powi(2) + (p[1] - cy).powi(2)).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_geometry_is_sanitized() {
let mut pts = vec![[f32::NAN, 1.0], [2.0, f32::INFINITY], [1.0, 2.0], [1e30, -1e30]];
pts.extend(std::iter::repeat_n([3.0, 3.0], MAX_POINTS * 2));
let f = Freeform { w: f32::NAN, h: -4.0, paths: vec![FreePath { pts, closed: false }], alpha: 7.0, ink: None, order: None }.sanitized();
assert_eq!((f.w, f.h, f.alpha), (0.0, 0.0, 1.0));
let p = &f.paths[0].pts;
assert_eq!(p.len(), MAX_POINTS);
assert_eq!(p[0], [1.0, 2.0]);
assert_eq!(p[1], [MAX_COORD, -MAX_COORD]);
}
#[test]
fn distance_to_a_stroke_scales_with_its_frame() {
let f = Freeform::ink(InkTool::Pen, 10.0, 10.0, vec![[0.0, 0.0], [10.0, 0.0]]);
// Frame twice the size at (100, 100): the stroke runs from (100,100) to (120,100).
assert_eq!(f.distance(100.0, 100.0, 20.0, 20.0, 110.0, 103.0), Some(3.0));
assert_eq!(Freeform::default().distance(0.0, 0.0, 1.0, 1.0, 0.0, 0.0), None);
}
}
+2 -47
View File
@@ -1,14 +1,11 @@
//! Charts, SmartArt diagrams and native ink: drawings kept as neutral items ready to draw. Charts and SmartArt
//! WordCraft can edit also keep their model ([`crate::chart::ChartSpec`],
//! [`crate::smart_art::SmartArtSpec`]); the rest are shown, not edited.
//! Charts and SmartArt diagrams: drawings shown but not edited, kept as neutral items ready to draw.
use serde::{Deserialize, Serialize};
use crate::para::ShapeKind;
use crate::props::Rgb;
/// A chart, SmartArt diagram or native ink preview, as items ready to draw (and, for an editable chart or SmartArt
/// graphic, its model).
/// A drawing shown but not edited: a chart or a SmartArt diagram, as items ready to draw.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Graphic {
@@ -22,45 +19,6 @@ pub struct Graphic {
pub w: f32,
#[serde(default)]
pub h: f32,
/// Where the graphic was read from, so saving writes the original drawing itself back (not
/// only these items). `None` for graphics made some other way.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<std::sync::Arc<Embedded>>,
/// A SmartArt graphic WordCraft can edit (one it made, or one read back exactly as it wrote
/// it): saving writes the diagram parts from it, and the items are drawn from it. See
/// [`crate::smart_art`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smart_art: Option<std::sync::Arc<crate::smart_art::SmartArtSpec>>,
/// A chart WordCraft can edit (one it made, or one read back that holds nothing more): saving
/// writes the chart part from it, and the items are drawn from it. See [`crate::chart`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chart: Option<std::sync::Arc<crate::chart::ChartSpec>>,
}
/// What an object read from a file needs to be written back as it was: its own markup in the
/// source format and the package parts that markup refers to. Never edited; WordCraft only moves
/// and resizes the object around it. The parts themselves (bytes, content type and their own
/// relationships) are kept in [`crate::Document::passthrough`] under their part names.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Embedded {
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
/// the `w:object` of an OLE object, or the `w:pict` of a signature line).
pub xml: String,
/// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing).
pub refs: Vec<EmbeddedRef>,
}
/// One relationship an [`Embedded`] object uses.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedRef {
/// The relationship id the markup uses.
pub id: String,
/// Relationship type (a URI).
pub kind: String,
/// The part name (a `Document::passthrough` key), or the URL of an external target.
pub target: String,
pub external: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -69,8 +27,6 @@ pub enum GraphicKind {
#[default]
Chart,
Diagram,
/// Word's native InkML, kept opaque; displayed as its fallback picture when available.
Ink,
}
impl GraphicKind {
@@ -79,7 +35,6 @@ impl GraphicKind {
match self {
GraphicKind::Chart => "chart",
GraphicKind::Diagram => "diagram",
GraphicKind::Ink => "ink",
}
}
}
-712
View File
@@ -1,712 +0,0 @@
//! Visible logical text for IME surrounding-text queries.
//!
//! The platform input method works on what the reader sees, not on the stored run text: tracked
//! deletions are hidden, and an inline object stands for its plain-text result. The helpers here
//! build a bounded window around a selection without cloning the paragraph or the document, and
//! map both ends in both directions so a query and a delete use exactly the same text.
use std::ops::Range;
use crate::para::{OBJ, Paragraph};
use crate::{Document, Pos};
/// A bounded visible-logical window around a selection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeWindow {
/// At most [`MAX_IME_TEXT_BYTES`] bytes of visible committed text.
pub text: String,
/// UTF-8 byte offset of the primary end in [`Self::text`].
pub cursor: usize,
/// UTF-8 byte offset of the other end in [`Self::text`].
pub anchor: usize,
}
/// The largest visible window an IME query copies.
pub const MAX_IME_TEXT_BYTES: usize = 4000;
/// Which unit an input method's delete-surrounding counts use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeleteUnit {
/// Unicode scalar values of the visible text.
Chars,
/// UTF-8 bytes of the visible text.
Bytes,
}
/// A deletion request in the paragraph's stored byte offsets.
///
/// The selection itself is never part of `before` or `after`; both ranges are outside it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeDelete {
/// Stored byte range to erase before the selection.
pub before: Option<Range<usize>>,
/// Stored byte range to erase after the selection.
pub after: Option<Range<usize>>,
/// Stored byte offset of the selection's anchor **before** the deletions.
///
/// The ranges are returned in the paragraph's stored text as it is now; the caller applies
/// them (and shifts these offsets past `before`) itself.
pub anchor: usize,
/// Stored byte offset of the selection's focus **before** the deletions.
pub focus: usize,
}
/// One paragraph's visible logical text.
///
/// Deleted runs are skipped and inline objects contribute their plain-text result. Iteration is
/// lazy: no full visible copy is made.
pub struct VisibleParagraph<'a> {
para: &'a Paragraph,
deleted: Vec<Range<usize>>,
}
impl<'a> VisibleParagraph<'a> {
/// A view of `para` with tracked deletions hidden.
pub fn new(para: &'a Paragraph) -> Self {
let mut deleted = para.deleted_ranges();
deleted.sort_unstable_by_key(|r| r.start);
Self { para, deleted }
}
/// Whether `off` falls inside a tracked deletion, advancing `next` past the runs that end
/// before it.
///
/// The ranges are sorted, so one monotone cursor makes a whole scan O(text + ranges) instead
/// of O(text × ranges): alternating tracked revisions used to make every character scan every
/// range.
fn is_deleted_at(&self, next: &mut usize, off: usize) -> bool {
while let Some(r) = self.deleted.get(*next) {
if r.end > off {
break;
}
*next = next.saturating_add(1);
}
self.deleted.get(*next).is_some_and(|r| r.start <= off && off < r.end)
}
/// Call `f` for every visible piece: `(stored byte start, piece, stored byte len)`.
fn for_each_piece(&self, mut f: impl FnMut(usize, &str, usize)) {
self.for_each_piece_while(|start, piece, stored| {
f(start, piece, stored);
true
});
}
/// Call `f` for visible pieces until it returns `false`.
fn for_each_piece_while(&self, mut f: impl FnMut(usize, &str, usize) -> bool) {
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let keep_going = if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
f(i, piece, OBJ.len_utf8())
} else {
let mut buf = [0u8; 4];
f(i, c.encode_utf8(&mut buf), c.len_utf8())
};
if !keep_going {
break;
}
}
}
/// Total visible UTF-8 bytes.
pub fn visible_len(&self) -> usize {
let mut len = 0usize;
self.for_each_piece(|_, piece, _| len = len.saturating_add(piece.len()));
len
}
/// Visible offset of a stored paragraph byte offset.
///
/// `None` when the stored offset is not a character boundary, points inside a tracked
/// deletion, or would split an inline object's plain-text result.
pub fn visible_offset(&self, stored: usize) -> Option<usize> {
if stored > self.para.text.len() || !self.para.text.is_char_boundary(stored) {
return None;
}
let mut visible = 0usize;
let mut found = false;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if i == stored {
found = true;
break;
}
if i > stored {
break;
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
// A position at the start of a deleted run is still a visible position; one
// inside it is not.
continue;
}
if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
visible = visible.saturating_add(piece.len());
} else {
visible = visible.saturating_add(c.len_utf8());
}
}
if !found {
// The end of the paragraph, or a deleted run at its end.
let mut next = 0usize;
return (!self.is_deleted_at(&mut next, stored)).then_some(visible);
}
if stored < self.para.text.len() && self.is_deleted_at(&mut deleted, stored) {
// The start of a deleted run is still a visible caret position; only a position
// strictly inside one is unrepresentable.
let at_start = self.deleted.iter().any(|r| r.start == stored);
return at_start.then_some(visible);
}
Some(visible)
}
/// Stored paragraph byte offset for a visible offset.
///
/// `None` when the visible offset falls inside an inline object's plain-text result or is
/// not a boundary of the visible representation.
pub fn stored_offset(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut answer = None;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc {
answer = Some(i);
break;
}
if visible < acc.saturating_add(piece_len) {
// Inside a character (or an object's expansion): no stored boundary there.
return None;
}
acc = acc.saturating_add(piece_len);
}
answer.or_else(|| (visible == acc).then_some(self.para.text.len()))
}
/// Stored offset for the **start** of a visible range, skipping zero-length objects at
/// the same boundary.
///
/// A content control's markers are inline objects with no plain text, so they share a
/// visible offset with the content just inside (or after) them. A range start must map
/// after a control-start marker and after a control-end marker, never onto the marker
/// itself: an IME deletion must not break the control. Non-empty object expansions still
/// map to the object's own stored offset, so a range that reaches one deletes it whole.
pub fn stored_offset_after(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc && piece_len > 0 {
return Some(i);
}
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Stored offset for a visible offset, preferring the position **before** a tracked
/// deletion that starts there. Use this for the end of a range so a deletion run just
/// after the caret is never drawn into it.
pub fn stored_offset_before(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if visible == acc {
return Some(i);
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Round a visible byte offset forward to the next visible boundary (inward for a start).
pub fn ceil_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = None;
self.for_each_piece(|_, piece, _| {
if out.is_some() {
return;
}
if acc >= visible {
out = Some(acc);
return;
}
acc = acc.saturating_add(piece.len());
if acc >= visible {
out = Some(acc);
}
});
out.unwrap_or(acc)
}
/// Round a visible byte offset backward to the previous visible boundary (inward for an end).
pub fn floor_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = 0usize;
self.for_each_piece(|_, piece, _| {
let next = acc.saturating_add(piece.len());
if next > visible {
return;
}
acc = next;
out = acc;
});
out
}
/// A bounded window with `cursor` and `anchor` as visible byte offsets.
///
/// The selection is never split and the window never exceeds [`MAX_IME_TEXT_BYTES`]. A
/// selection wider than that, an invalid offset, or an offset that cannot be represented as
/// visible text returns `None` (unavailable, not fabricated).
pub fn window(&self, cursor: usize, anchor: usize, max_bytes: usize) -> Option<ImeWindow> {
// The public API caps its own budget: a caller cannot ask for an unbounded copy.
let max_bytes = max_bytes.min(MAX_IME_TEXT_BYTES);
let (sel_start, sel_end) = (cursor.min(anchor), cursor.max(anchor));
let selection_len = sel_end - sel_start;
if selection_len > max_bytes {
return None;
}
// Spend the budget around the selection and stop walking at its end: the paragraph is
// never copied and the work is bounded by the caret's distance from the paragraph start.
let before = (max_bytes - selection_len) / 2;
let window_start = sel_start.saturating_sub(before);
let window_end = window_start.saturating_add(max_bytes);
let mut text = String::with_capacity(window_end.saturating_sub(window_start).min(max_bytes));
let mut first_kept = None;
let mut acc = 0usize;
self.for_each_piece_while(|_, piece, _| {
for (k, c) in piece.char_indices() {
let start = acc.saturating_add(k);
let end = start.saturating_add(c.len_utf8());
if end > window_end {
return false;
}
if start >= window_start {
if first_kept.is_none() {
first_kept = Some(start);
}
text.push(c);
}
}
acc = acc.saturating_add(piece.len());
acc < window_end
});
let Some(first) = first_kept else {
// An empty document is an available empty context, never unavailable.
return (cursor == 0 && anchor == 0).then_some(ImeWindow { text: String::new(), cursor: 0, anchor: 0 });
};
Some(ImeWindow { text, cursor: cursor.saturating_sub(first), anchor: anchor.saturating_sub(first) })
}
}
impl Document {
/// Bounded visible logical text around `cursor`/`anchor` in one paragraph.
///
/// A selection across paragraphs or blocks is unrepresentable and returns `None`.
pub fn ime_surrounding(&self, cursor: &Pos, anchor: &Pos, max_bytes: usize) -> Option<ImeWindow> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
view.window(c, a, max_bytes.min(MAX_IME_TEXT_BYTES))
}
/// Delete-surrounding ranges in the stored paragraph text.
///
/// Counts exclude the selection, tracked deletions and any pre-edit (which never lives in
/// the model here). Byte counts that end inside a scalar leave it whole: a start rounds
/// forward and an end rounds backward; a byte count beyond the visible text is refused.
/// Character counts clamp at the paragraph's ends, as the web text agent's do. A range that
/// cannot be represented in stored text returns `None` instead of guessing; so does a
/// request with nothing to erase (`0` on both sides).
pub fn ime_delete_surrounding(&self, cursor: &Pos, anchor: &Pos, before: usize, after: usize, unit: DeleteUnit) -> Option<ImeDelete> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
if before == 0 && after == 0 {
// Nothing to erase: don't build the boundary table (or walk a long paragraph) to
// discover it.
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
let (sel_start, sel_end) = (c.min(a), c.max(a));
let (before_start, after_end) = match unit {
DeleteUnit::Bytes => {
let total = view.visible_len();
if before > sel_start || after > total - sel_end {
return None;
}
(view.ceil_boundary(sel_start.saturating_sub(before)), view.floor_boundary(sel_end.saturating_add(after)))
}
DeleteUnit::Chars => char_boundaries(&view, sel_start, sel_end, before, after)?,
};
// Start a range after a deletion run and end it before one, so tracked deletions are
// never part of the erased stored range.
let before_start = view.stored_offset_after(before_start)?;
let before_end = view.stored_offset_before(sel_start)?;
let after_start = view.stored_offset_after(sel_end)?;
let after_end = view.stored_offset_before(after_end)?;
let before_range = (before_start < before_end).then_some(before_start..before_end);
let after_range = (after_start < after_end).then_some(after_start..after_end);
Some(ImeDelete { before: before_range, after: after_range, anchor: anchor.off, focus: cursor.off })
}
}
/// The visible byte boundaries `before` characters back from `sel_start` and `after`
/// characters on from `sel_end`.
///
/// Two walks and constant memory replace the old per-character `Vec` that held every boundary
/// (a hostile count could make it allocate the whole paragraph). Character counts clamp at the
/// paragraph's ends; `None` when either selection end is not a boundary of the visible text.
fn char_boundaries(view: &VisibleParagraph<'_>, sel_start: usize, sel_end: usize, before: usize, after: usize) -> Option<(usize, usize)> {
let mut acc = 0usize;
let mut chars = 0usize;
let mut start_index = None;
let mut end_index = None;
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if at == sel_start {
start_index = Some(chars);
}
if at == sel_end {
end_index = Some(chars);
}
chars = chars.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if sel_start == acc {
start_index = Some(chars);
}
if sel_end == acc {
end_index = Some(chars);
}
let start_index = start_index?;
let end_index = end_index?;
let target_before = start_index.saturating_sub(before);
let target_after = end_index.saturating_add(after).min(chars);
let mut acc = 0usize;
let mut index = 0usize;
let mut before_start = None;
let mut after_end = (target_after == 0).then_some(0);
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if index == target_before && before_start.is_none() {
before_start = Some(at);
}
if index == target_after && after_end.is_none() {
after_end = Some(at);
}
index = index.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if index == target_before && before_start.is_none() {
before_start = Some(acc);
}
if index == target_after && after_end.is_none() {
after_end = Some(acc);
}
Some((before_start?, after_end?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::para::Run;
use crate::props::CharProps;
use crate::{InlineObject, StoryRef};
/// Builds a paragraph with one run per `(text, deleted)` part; deleted parts carry revision 0.
fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph {
let mut p = Paragraph::new();
for (text, deleted) in parts {
let mut props = CharProps::default();
if *deleted {
props.del = Some(0);
}
p.text.push_str(text);
p.runs.push(Run { len: text.len(), props });
}
p
}
/// A tracked deletion is hidden from the IME view but still maps stored offsets: its start is a
/// visible caret position, its interior is not, and a window concatenates only visible text.
#[test]
fn tracked_deletions_are_hidden_and_offsets_still_map() {
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 4);
assert_eq!(view.visible_offset(0), Some(0));
assert_eq!(view.visible_offset(2), Some(2), "the start of a deleted run is a visible caret position");
assert_eq!(view.visible_offset(3), None, "inside a tracked deletion");
assert_eq!(view.visible_offset(5), Some(2));
assert_eq!(view.visible_offset(7), Some(4));
assert_eq!(view.stored_offset(2), Some(5));
assert_eq!(view.stored_offset(4), Some(7));
assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into()));
}
/// An inline object contributes its plain text to visible length and boundary mapping; offsets
/// strictly inside that expansion have no stored boundary.
#[test]
fn objects_contribute_their_plain_text_and_split_mapping_stops() {
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 5);
assert_eq!(view.visible_offset(1), Some(1));
assert_eq!(view.visible_offset(4), Some(4));
assert_eq!(view.stored_offset(1), Some(1));
assert_eq!(view.stored_offset(4), Some(4));
assert_eq!(view.stored_offset(2), None, "inside the object's text");
}
/// An empty paragraph yields an available empty IME context at offset 0; an empty document
/// yields an available empty context too, though this test asserts only its text, not its offsets.
#[test]
fn an_empty_document_is_available_and_empty() {
let p = Paragraph::new();
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, MAX_IME_TEXT_BYTES).expect("an available empty context");
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("", 0, 0));
let doc = Document::new();
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), MAX_IME_TEXT_BYTES).expect("available");
assert_eq!(w.text, "");
}
/// A window is capped at the IME byte budget while keeping cursor and anchor inside its text; a
/// selection wider than the budget is unavailable.
#[test]
fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(5000, 5000, MAX_IME_TEXT_BYTES).expect("a bounded window");
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
assert_eq!(w.cursor, w.anchor);
assert!(w.cursor <= w.text.len());
assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none());
}
/// Byte deletion rounds its start to a visible UTF-8 boundary, leaves partially covered scalar
/// values whole, and refuses counts that run past the paragraph.
#[test]
fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() {
let doc = Document::from_text("aé👍b");
let caret = Pos::body(0, 8);
let one = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(one.before, Some(7..8));
// Two bytes start inside the emoji: it is left whole, only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(two.before, Some(7..8));
// Six bytes start inside `é`: it stays whole, the emoji and `b` go.
let six = doc.ime_delete_surrounding(&caret, &caret, 6, 0, DeleteUnit::Bytes).expect("emoji and b");
assert_eq!(six.before, Some(3..8));
// A count beyond the paragraph is refused, not clipped to delete a whole side.
assert!(doc.ime_delete_surrounding(&caret, &caret, 0, 1, DeleteUnit::Bytes).is_none());
assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none());
}
/// Surrounding deletion skips the selected range on both sides and returns the selection's
/// original reversed anchor and focus.
#[test]
fn byte_deletion_excludes_a_reversed_selection() {
let doc = Document::from_text("abcd");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(0, 3);
let del = doc.ime_delete_surrounding(&cursor, &anchor, 1, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(0..1), "only the character before the selection");
assert_eq!(del.after, Some(3..4), "only the character after the selection");
assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved");
}
/// Tracked deletions are absent from windows and skipped when deleting around a caret, so an IME
/// edit cannot remove them.
#[test]
fn tracked_deletions_are_not_counted_for_deletion_or_query() {
let mut doc = Document::from_text("abDELcd");
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let view = VisibleParagraph::new(doc.para_at(&Pos::body(0, 0)).expect("paragraph"));
assert_eq!(view.window(2, 2, 100).map(|w| w.text), Some("abcd".into()));
let caret = Pos::body(0, 7);
let del = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("c");
assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped");
}
/// A selection spanning paragraphs has no single surrounding context: both the query and
/// surrounding deletion return `None`.
#[test]
fn cross_paragraph_selection_is_unavailable() {
let doc = Document::from_text("one\ntwo");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(1, 1);
assert!(doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).is_none());
assert!(doc.ime_delete_surrounding(&cursor, &anchor, 1, 0, DeleteUnit::Chars).is_none());
}
/// An object's plain-text result stands for the object in the visible text; a deletion
/// that starts inside that expansion leaves the object whole (byte) or is refused (chars).
#[test]
fn object_expansion_is_never_partly_deleted() {
let mut doc = Document::from_text("");
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let caret = Pos::body(0, 5);
// Two bytes before the caret end inside `x+y` (`y` is the last byte): the start rounds
// forward past the object, so only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b only");
assert_eq!(two.before, Some(4..5), "stored UTF-8 offsets keep the object whole");
let mut remaining = format!("a{OBJ}b");
remaining.replace_range(two.before.clone().unwrap(), "");
assert_eq!(remaining, format!("a{OBJ}"));
// A character that lands inside the expansion has no stored boundary: refused.
assert!(doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Chars).is_none());
// A count reaching before the object erases the object as a whole.
let five = doc.ime_delete_surrounding(&caret, &caret, 5, 0, DeleteUnit::Bytes).expect("object and b");
assert_eq!(five.before, Some(0..5));
}
/// Character counts on the right side clamp at the paragraph's end and never cross into the
/// next paragraph.
#[test]
fn trailing_character_deletion_clamps_at_the_paragraph_end() {
let doc = Document::from_text("ab\ncd");
let caret = Pos::body(0, 1);
let del = doc.ime_delete_surrounding(&caret, &caret, 0, 99, DeleteUnit::Chars).expect("b only");
assert_eq!(del.after, Some(1..2));
let empty = doc.ime_delete_surrounding(&caret, &caret, 0, 0, DeleteUnit::Chars);
assert!(empty.is_none(), "nothing to erase");
}
/// Sorted tracked deletions are scanned with one cursor: alternating revisions stay correct
/// (and linear) instead of each character scanning every range.
#[test]
fn alternating_tracked_revisions_map_correctly() {
// Equal-length visible/deleted pieces, so the expected visible byte count is exact.
let parts: Vec<(String, bool)> =
(0..2000).map(|i| (if i % 2 == 0 { format!("v{i:04};") } else { format!("D{i:04};") }, i % 2 == 1)).collect();
let parts: Vec<(&str, bool)> = parts.iter().map(|(t, d)| (t.as_str(), *d)).collect();
let p = para_with_runs(&parts);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), p.text.len() - 6 * 1000);
assert_eq!(view.visible_offset(p.text.len()), Some(view.visible_len()));
let w = view.window(0, 0, 100).expect("a window");
assert!(w.text.len() <= 100);
assert!(!w.text.contains('D'), "tracked deletions never show");
}
/// The public window never copies more than the IME cap, whatever a caller asks for.
#[test]
fn window_budget_is_capped() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, usize::MAX).expect("a bounded window");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
let doc = Document::from_text(&"a".repeat(10_000));
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), usize::MAX).expect("bounded");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
}
/// A caret in a table-cell paragraph maps against that cell's text, not the body around the table.
#[test]
fn table_cell_paragraph_maps_inside_its_cell() {
let mut doc = Document::from_text("outside");
let mut table = crate::Table::new(1, 1, 100.0);
table.rows[0].cells[0].blocks = vec![crate::para_block(Paragraph::with_text("cell", CharProps::default()))];
doc.body.push(std::sync::Arc::new(crate::Block::Table(table)));
let path = crate::Path(vec![1, 0, 0, 0]);
let cursor = Pos { story: StoryRef::Body, path: path.clone(), off: 4 };
let anchor = Pos { story: StoryRef::Body, path, off: 2 };
let w = doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).expect("the cell text");
assert_eq!(w.text, "cell");
assert_eq!(w.cursor, 4);
assert_eq!(w.anchor, 2);
}
/// Content-control markers are zero-length visible objects. A deletion range starts after
/// the marker and ends before the next one, so an ordinary IME edit never removes a marker
/// or leaves a half-paired control.
#[test]
fn control_markers_are_not_included_in_delete_ranges() {
use crate::control::{ContentControl, ControlKind};
let mut p = Paragraph::new();
p.text = format!("pre{OBJ}Ada{OBJ}X");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
let mut ctl = ContentControl::new(ControlKind::RichText);
ctl.placeholder_text = "Enter name".into();
p.objects.push(InlineObject::ControlStart { control: Box::new(ctl) });
p.objects.push(InlineObject::ControlEnd);
let mut doc = Document::new();
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
// Stored positions are UTF-8 byte offsets; each OBJ marker occupies three bytes.
let cursor = Pos::body(0, 9);
let del = doc.ime_delete_surrounding(&cursor, &cursor, 3, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(6..9), "the control-start marker and `pre` stay outside");
assert_eq!(del.after, Some(12..13), "the control-end marker stays outside; only `X` goes");
assert_eq!((del.anchor, del.focus), (9, 9));
}
}
-163
View File
@@ -1,163 +0,0 @@
//! Legacy form fields (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): what their `w:ffData` says,
//! read from the raw XML a [`crate::InlineObject::Field`] keeps in its `form` (ECMA-376 Part 1
//! §17.16.17). Only what layout and PDF export need; the XML itself stays the source of truth
//! for saving.
/// Most drop-down entries kept (Word allows 25).
pub const MAX_ENTRIES: usize = 64;
/// A legacy form field's kind and state.
#[derive(Clone, Debug, PartialEq)]
pub enum LegacyForm {
/// A check box: `checked` is its state (`w:checked`, else `w:default`), `size` its explicit
/// size in points (`w:size`), `None` when it follows the text (`w:sizeAuto`).
CheckBox { name: String, checked: bool, size: Option<f32> },
/// A text field; its text is the field's result.
Text { name: String, max_len: Option<u32> },
/// A drop-down list showing entry `selected` (`w:result`, else `w:default`, else the first).
DropDown { name: String, entries: Vec<String>, selected: usize },
}
impl LegacyForm {
/// The field's definition, or `None` when `ff_data` holds none of the three.
pub fn parse(ff_data: &str) -> Option<LegacyForm> {
let name = attr_of(ff_data, "w:name").unwrap_or_default();
if let Some(cb) = element(ff_data, "w:checkBox") {
let default = attr_of(cb, "w:default").is_some_and(|v| on(&v));
// `<w:checked/>` with no value means checked.
let checked = match tag(cb, "w:checked") {
Some(t) => attr_in(t).is_none_or(|v| on(&v)),
None => default,
};
let size =
attr_of(cb, "w:size").and_then(|v| v.trim().parse::<f32>().ok()).filter(|v| v.is_finite() && *v > 0.0).map(|v| (v / 2.0).min(1584.0));
return Some(LegacyForm::CheckBox { name, checked, size });
}
if let Some(dd) = element(ff_data, "w:ddList") {
let entries: Vec<String> = tags(dd, "w:listEntry").filter_map(attr_in).take(MAX_ENTRIES).collect();
let pick = |n: &str| attr_of(dd, n).and_then(|v| v.trim().parse::<usize>().ok());
let selected = pick("w:result").or_else(|| pick("w:default")).filter(|i| *i < entries.len()).unwrap_or(0);
return Some(LegacyForm::DropDown { name, entries, selected });
}
if ff_data.contains("<w:textInput") {
let max_len =
element(ff_data, "w:textInput").and_then(|t| attr_of(t, "w:maxLength")).and_then(|v| v.trim().parse::<u32>().ok()).filter(|n| *n > 0);
return Some(LegacyForm::Text { name, max_len });
}
None
}
/// The text a drop-down shows (its selected entry); `None` for other kinds or an empty list.
pub fn shown_text(&self) -> Option<&str> {
match self {
LegacyForm::DropDown { entries, selected, .. } => entries.get(*selected).map(String::as_str),
_ => None,
}
}
/// The field's bookmark-style name (`w:name`), possibly empty.
pub fn name(&self) -> &str {
match self {
LegacyForm::CheckBox { name, .. } | LegacyForm::Text { name, .. } | LegacyForm::DropDown { name, .. } => name,
}
}
}
fn on(v: &str) -> bool {
!matches!(v.trim(), "0" | "false" | "off")
}
/// The element `name` in `xml` with its content: from its start tag to its end tag, or the
/// self-closing tag alone.
fn element<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
let open = tag(xml, name)?;
let start = xml.find(open)?;
if open.ends_with("/>") {
return Some(open);
}
let close = format!("</{name}>");
let end = xml.get(start..)?.find(&close)? + start + close.len();
xml.get(start..end)
}
/// The first start (or empty) tag `<name ...>` in `xml`.
fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
tags(xml, name).next()
}
/// Every start (or empty) tag `<name ...>` in `xml`, in order.
fn tags<'a>(xml: &'a str, name: &str) -> impl Iterator<Item = &'a str> + use<'a> {
let pat = format!("<{name}");
let mut from = 0;
std::iter::from_fn(move || {
loop {
let at = from + xml.get(from..)?.find(&pat)?;
let after = at + pat.len();
from = after;
// `<w:checkBox` must not match `<w:checkBoxes`: the name ends at a space, `/` or `>`.
if xml.get(after..).and_then(|s| s.chars().next()).is_some_and(|c| c == ' ' || c == '/' || c == '>') {
let end = after + xml.get(after..)?.find('>')? + 1;
return xml.get(at..end);
}
}
})
}
/// The `w:val` of the first tag `name` in `xml`.
fn attr_of(xml: &str, name: &str) -> Option<String> {
tag(xml, name).and_then(attr_in)
}
/// The `w:val` of a single tag, XML entities resolved.
fn attr_in(tag: &str) -> Option<String> {
let at = tag.find("w:val=")? + "w:val=".len();
let quote = tag.get(at..)?.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = tag.get(at + 1..)?;
let end = rest.find(quote)?;
Some(unescape(rest.get(..end)?))
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_boxes_text_fields_and_drop_downs() {
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
assert_eq!(
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="0"/>"#)),
Some(LegacyForm::CheckBox { name: "Box1".into(), checked: false, size: None })
);
assert_eq!(
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="1"/>"#)).map(|f| matches!(f, LegacyForm::CheckBox { checked: true, .. })),
Some(true)
);
// `w:checked` wins over `w:default`; without a value it means checked.
assert!(matches!(
LegacyForm::parse(&cb(r#"<w:default w:val="1"/><w:checked w:val="0"/>"#)),
Some(LegacyForm::CheckBox { checked: false, .. })
));
assert!(matches!(LegacyForm::parse(&cb(r#"<w:default w:val="0"/><w:checked/>"#)), Some(LegacyForm::CheckBox { checked: true, .. })));
assert!(matches!(
LegacyForm::parse(&cb(r#"<w:size w:val="30"/><w:default w:val="0"/>"#)),
Some(LegacyForm::CheckBox { size: Some(15.0), .. })
));
let dd = r#"<w:ffData><w:name w:val="List1"/><w:ddList><w:result w:val="1"/><w:listEntry w:val="Alpha"/><w:listEntry w:val="B &amp; C"/></w:ddList></w:ffData>"#;
let f = LegacyForm::parse(dd).unwrap();
assert_eq!(f.shown_text(), Some("B & C"));
assert_eq!(f.name(), "List1");
let dd0 = r#"<w:ffData><w:ddList><w:listEntry w:val="Only"/></w:ddList></w:ffData>"#;
assert_eq!(LegacyForm::parse(dd0).and_then(|f| f.shown_text().map(str::to_string)).as_deref(), Some("Only"));
let tx = r#"<w:ffData><w:name w:val="Name1"/><w:textInput><w:maxLength w:val="40"/></w:textInput></w:ffData>"#;
assert_eq!(LegacyForm::parse(tx), Some(LegacyForm::Text { name: "Name1".into(), max_len: Some(40) }));
assert_eq!(LegacyForm::parse(""), None);
assert_eq!(LegacyForm::parse("<w:ffData><w:name w:val=\"x\"/></w:ffData>"), None);
}
}
+58 -1031
View File
File diff suppressed because it is too large Load Diff
-124
View File
@@ -1,124 +0,0 @@
//! DrawingML outline dashes, measured in multiples of the stroke width.
use serde::{Deserialize, Serialize};
pub const DASH_PERCENT: u32 = 100_000;
pub const MAX_DASH_STOPS: usize = 64;
const MIN_DASH_POINTS: f32 = 0.01;
const MAX_DASH_POINTS: f32 = 20_000.0;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineDash {
#[default]
Solid,
Dot,
Dash,
LongDash,
DashDot,
LongDashDot,
LongDashDotDot,
SystemDash,
SystemDot,
SystemDashDot,
SystemDashDotDot,
/// Dash/space pairs in 100000ths of the line width (`a:ds/@d`, `@sp`).
Custom(Vec<(u32, u32)>),
}
impl LineDash {
pub fn from_ooxml(value: &str) -> Self {
match value {
"dot" => Self::Dot,
"dash" => Self::Dash,
"lgDash" => Self::LongDash,
"dashDot" => Self::DashDot,
"lgDashDot" => Self::LongDashDot,
"lgDashDotDot" => Self::LongDashDotDot,
"sysDash" => Self::SystemDash,
"sysDot" => Self::SystemDot,
"sysDashDot" => Self::SystemDashDot,
"sysDashDotDot" => Self::SystemDashDotDot,
_ => Self::Solid,
}
}
pub fn ooxml(&self) -> Option<&'static str> {
Some(match self {
Self::Solid => "solid",
Self::Dot => "dot",
Self::Dash => "dash",
Self::LongDash => "lgDash",
Self::DashDot => "dashDot",
Self::LongDashDot => "lgDashDot",
Self::LongDashDotDot => "lgDashDotDot",
Self::SystemDash => "sysDash",
Self::SystemDot => "sysDot",
Self::SystemDashDot => "sysDashDot",
Self::SystemDashDotDot => "sysDashDotDot",
Self::Custom(_) => return None,
})
}
pub fn is_solid(&self) -> bool {
*self == Self::Solid
}
/// Page-point lengths for the painters. Bound hostile custom lists and tiny spans.
pub fn pattern(&self, width: f32) -> Vec<f32> {
let preset: &[f32] = match self {
Self::Solid => return Vec::new(),
Self::Dot => &[1.0, 3.0],
Self::Dash => &[4.0, 3.0],
Self::LongDash => &[8.0, 3.0],
Self::DashDot => &[4.0, 3.0, 1.0, 3.0],
Self::LongDashDot => &[8.0, 3.0, 1.0, 3.0],
Self::LongDashDotDot => &[8.0, 3.0, 1.0, 3.0, 1.0, 3.0],
Self::SystemDash => &[3.0, 1.0],
Self::SystemDot => &[1.0, 1.0],
Self::SystemDashDot => &[3.0, 1.0, 1.0, 1.0],
Self::SystemDashDotDot => &[3.0, 1.0, 1.0, 1.0, 1.0, 1.0],
Self::Custom(stops) => {
return stops
.iter()
.take(MAX_DASH_STOPS)
.flat_map(|&(d, sp)| [d, sp])
.map(|v| dash_points(v as f32 / DASH_PERCENT as f32, width))
.collect();
}
};
preset.iter().map(|v| dash_points(*v, width)).collect()
}
}
fn dash_points(ratio: f32, width: f32) -> f32 {
let width = if width.is_finite() { width.clamp(0.25, 200.0) } else { 0.75 };
(ratio * width).clamp(MIN_DASH_POINTS, MAX_DASH_POINTS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dash_units_follow_line_width() {
assert_eq!(LineDash::Dash.pattern(1.0), [4.0, 3.0]);
assert_eq!(LineDash::Dash.pattern(2.0), [8.0, 6.0]);
assert_eq!(LineDash::Custom(vec![(200_000, 150_000)]).pattern(2.0), [4.0, 3.0]);
assert!(LineDash::Solid.pattern(2.0).is_empty());
}
#[test]
fn old_shapes_default_to_solid() {
let extra: crate::connector::ShapeExtra = serde_json::from_str("{}").unwrap();
assert!(extra.dash.is_solid());
assert!(!serde_json::to_string(&extra).unwrap().contains("dash"));
}
#[test]
fn custom_dashes_are_bounded() {
let pattern = LineDash::Custom(vec![(0, u32::MAX); MAX_DASH_STOPS + 1]).pattern(f32::NAN);
assert_eq!(pattern.len(), MAX_DASH_STOPS * 2);
assert!(pattern.iter().all(|v| v.is_finite() && *v >= MIN_DASH_POINTS && *v <= MAX_DASH_POINTS));
}
}
-95
View File
@@ -1,95 +0,0 @@
//! Which hyperlink targets may leave the document: followed by the OS, or written into an
//! exported HTML/Markdown file where a browser would run them.
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
/// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading
/// space or a control character makes it not a link.
pub fn is_web_or_mail_link(url: &str) -> bool {
let Some((scheme, _)) = url.split_once(':') else { return false };
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
}
/// Whether a hyperlink may be written as an `href` (or Markdown destination, RTF `HYPERLINK`
/// field, ODT `xlink:href`, LaTeX `\href`) in an exported file: a web or email link, a bookmark
/// (`#name`) or a relative path. Scripts (`javascript:`, `vbscript:`), `data:`, `file:` and every
/// other scheme are refused, read the way a browser reads a URL: ASCII control characters and
/// spaces are dropped first, so `java\nscript:` is a script. A protocol-relative reference
/// (`//host/…`) or a UNC path (`\\host\…`, or `//host` once backslashes are read as separators)
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
return false;
}
match scheme(url) {
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
None => !cleaned.is_empty(),
}
}
/// The scheme a browser would see in `url` (RFC 3986 §3.1: a letter, then letters, digits, `+`,
/// `-` or `.`, up to the first `:`), lower-cased; `None` for a relative reference.
fn scheme(url: &str) -> Option<String> {
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
let (s, _) = cleaned.split_once(':')?;
let mut chars = s.chars();
let first = chars.next()?;
(first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_web_and_mail_links_are_followable() {
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
assert!(is_web_or_mail_link(ok), "{ok}");
}
for bad in [
"file:///etc/passwd",
"C:\\Windows\\System32\\calc.exe",
"/usr/bin/xterm",
"javascript:alert(1)",
"ms-msdt:/id",
" https://example.com",
"https\u{0}://example.com",
"",
"example.com",
"#top",
] {
assert!(!is_web_or_mail_link(bad), "{bad:?}");
}
}
/// An exported `href` may be a web/mail link, a bookmark or a relative path: never a script
/// or another scheme, however a browser would normalise it.
#[test]
fn exported_hrefs_allow_bookmarks_and_relative_paths_but_no_scripts() {
for ok in ["https://example.com/x", "mailto:a@b.c", "#top", "other.html", "docs/guide.html#s2", "../up.html", "?q=1"] {
assert!(is_safe_href(ok), "{ok}");
}
for bad in [
"javascript:alert(1)",
"JavaScript:alert(1)",
"java\nscript:alert(1)",
"\tjavascript:alert(1)",
" javascript:alert(1)",
"java\u{0}script:alert(1)",
"vbscript:MsgBox(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"ms-msdt:/id",
// Protocol-relative and UNC references name another machine (NTLM leak on Windows).
"//attacker.example/share/x",
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
}
}
}
-213
View File
@@ -1,213 +0,0 @@
//! Numbers written the way a language writes them: `1 250,50` in Polish, `1.250,50` in German,
//! `1,250.50` in English. Used by table sort and table formulas.
use crate::collate::language;
/// The decimal separator of language `lang` (a BCP 47 tag; `None` means English).
pub fn decimal_separator(lang: Option<&str>) -> char {
let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-");
// Regions that write a decimal point where the language usually writes a comma.
const POINT_REGIONS: &[&str] =
&["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv", "es-cu", "es-pe"];
if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) {
return '.';
}
const COMMA: &[&str] = &[
"af", "az", "be", "bg", "bs", "ca", "cs", "da", "de", "el", "es", "et", "eu", "fi", "fo", "fr", "gl", "hr", "hu", "hy", "id", "is", "it",
"ka", "kk", "ky", "lb", "lt", "lv", "mk", "mn", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk",
"uz", "vi",
];
if COMMA.contains(&language(lang).as_str()) { ',' } else { '.' }
}
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
fn strip_currency(s: &str) -> &str {
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
if let Some((head, word)) = s.rsplit_once(SPACES)
&& (1..=3).contains(&word.chars().count())
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
{
s = head;
}
if let Some((word, tail)) = s.split_once(SPACES)
&& word.chars().count() == 3
&& word.chars().all(|c| c.is_ascii_uppercase())
{
s = tail;
}
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
}
/// Digits of an integer part with group separators `groups`: the first group has 1–3 digits,
/// every later one exactly 3.
fn grouped_digits(int: &str, groups: &[char]) -> Option<String> {
if int.chars().all(|c| c.is_ascii_digit()) {
return Some(int.to_string());
}
let parts: Vec<&str> = int.split(groups).collect();
let (first, rest) = parts.split_first()?;
// A first group of `0` is never grouping (`0.125` is a fraction, not 125).
let ok = (1..=3).contains(&first.len())
&& !first.starts_with('0')
&& first.chars().all(|c| c.is_ascii_digit())
&& rest.iter().all(|g| g.len() == 3 && g.chars().all(|c| c.is_ascii_digit()));
ok.then(|| parts.concat())
}
/// Parse `s` as a number written in language `lang`, or `None` when it isn't one. The decimal
/// separator is the language's; group separators are spaces (and `.` or `'` where the decimal
/// separator is a comma, `,` or `'` where it is a point) between groups of three digits. A
/// currency sign or short currency word is ignored, and so is a trailing `%` (`12 %` is 12, as
/// table sort reads it). In comma languages a lone `.` that cannot be a group separator (`1.5`)
/// is read as a decimal point. A trailing separator with no digits after it (`1.`, an ordinal
/// in a numbered column) is the whole number. A minus may be `-`, `−` or an en dash.
pub fn parse_number(s: &str, lang: Option<&str>) -> Option<f64> {
let dec = decimal_separator(lang);
let s = s.trim();
let s = s.strip_suffix('%').map_or(s, str::trim_end);
let mut body = strip_currency(s);
let mut neg = false;
if let Some(rest) = body.strip_prefix(['-', '\u{2212}', '\u{2013}']) {
neg = true;
body = strip_currency(rest);
} else if let Some(rest) = body.strip_prefix('+') {
body = strip_currency(rest);
}
if body.is_empty() || body.len() > 400 {
return None;
}
let mut groups: Vec<char> = SPACES.to_vec();
groups.extend(['\'', '\u{2019}']);
groups.push(if dec == ',' { '.' } else { ',' });
let (int, frac) = match body.split_once(dec) {
Some((i, f)) => (i, Some(f)),
None => (body, None),
};
let int_digits = match grouped_digits(int, &groups) {
Some(d) => d,
None if dec == ',' && frac.is_none() && int.matches('.').count() == 1 => {
return parse_number(&format!("{}{}", if neg { "-" } else { "" }, int.replace('.', ",")), lang);
}
None => return None,
};
let frac_digits = match frac {
Some(f) if f.chars().all(|c| c.is_ascii_digit()) => f,
Some(_) => return None,
None => "",
};
if int_digits.is_empty() && frac_digits.is_empty() {
return None;
}
let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?;
if !v.is_finite() {
return None;
}
Some(if neg { -v } else { v })
}
/// Write `v` the way language `lang` writes a formula result: whole numbers without decimals,
/// others with two decimals and the language's decimal separator (`5,75` in Polish).
pub fn format_number(v: f64, lang: Option<&str>) -> String {
if !v.is_finite() {
return "0".into();
}
if v.fract() == 0.0 && v.abs() < 9.2e18 {
return format!("{}", v as i64);
}
let s = format!("{v:.2}");
let s = if s == "-0.00" { "0.00".to_string() } else { s };
if decimal_separator(lang) == ',' { s.replace('.', ",") } else { s }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polish_numbers() {
let pl = Some("pl-PL");
assert_eq!(parse_number("1,5", pl), Some(1.5));
assert_eq!(parse_number("3,50", pl), Some(3.5));
assert_eq!(parse_number("1 250,00", pl), Some(1250.0));
assert_eq!(parse_number("1\u{a0}250,00 zł", pl), Some(1250.0));
assert_eq!(parse_number("1.250,00", pl), Some(1250.0));
assert_eq!(parse_number("1.5", pl), Some(1.5));
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
assert_eq!(parse_number("abc", pl), None);
assert_eq!(parse_number("1,2,3", pl), None);
// From review: ordinals, a leading zero, percentages and an en dash minus.
assert_eq!(parse_number("1.", pl), Some(1.0), "a numbered column: 1.");
assert_eq!(parse_number("10.", pl), Some(10.0));
assert_eq!(parse_number("0.125", pl), Some(0.125), "a leading 0 is never a group");
assert_eq!(parse_number("0,125", pl), Some(0.125));
assert_eq!(parse_number("12 %", pl), Some(12.0));
assert_eq!(parse_number("12%", pl), Some(12.0));
assert_eq!(parse_number("\u{2013}2,5", pl), Some(-2.5));
assert_eq!(parse_number("%", pl), None);
assert_eq!(parse_number(".", pl), None);
assert_eq!(format_number(5.75, pl), "5,75");
assert_eq!(format_number(1350.0, pl), "1350");
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
}
#[test]
fn overflowing_digit_strings_are_not_numbers() {
assert_eq!(parse_number(&"1".repeat(350), None), None);
assert_eq!(parse_number(&"9".repeat(400), Some("pl-PL")), None);
assert_eq!(parse_number(&format!("-{}", "1".repeat(350)), None), None);
let big = parse_number(&"1".repeat(300), None);
assert!(big.is_some_and(f64::is_finite), "{big:?}");
}
#[test]
fn cuban_and_peruvian_numbers_use_decimal_points() {
for lang in [Some("es-CU"), Some("es-PE")] {
assert_eq!(parse_number("1.234", lang), Some(1.234));
assert_eq!(parse_number("1,234.50", lang), Some(1234.5));
assert_eq!(format_number(5.75, lang), "5.75");
}
assert_eq!(parse_number("1.234,50", Some("es-ES")), Some(1234.5));
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
}
#[test]
fn english_numbers() {
let en = Some("en-US");
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
assert_eq!(parse_number("$1,250", en), Some(1250.0));
assert_eq!(parse_number("1.5", en), Some(1.5));
assert_eq!(parse_number("12", None), Some(12.0));
assert_eq!(parse_number(".5", None), Some(0.5));
assert_eq!(parse_number("1,5", en), None);
assert_eq!(parse_number("1.", en), Some(1.0));
assert_eq!(parse_number("0,125", en), None, "never 125");
assert_eq!(format_number(5.75, en), "5.75");
assert_eq!(format_number(-0.001, en), "0.00", "a rounded zero has no sign");
assert_eq!(format_number(-0.0, None), "0");
assert_eq!(decimal_separator(Some("de-CH")), '.');
assert_eq!(decimal_separator(Some("de-DE")), ',');
}
#[test]
fn hostile_numbers_never_panic() {
for s in ["", "-", "+", ",", ".", "1e400", "inf", "NaN", "--1", "₽", "1 ,", " zł", "\u{2212}", "9".repeat(500).as_str()] {
for l in [None, Some("pl"), Some("en"), Some("")] {
let _ = parse_number(s, l);
}
}
assert_eq!(format_number(f64::NAN, None), "0");
}
#[test]
fn large_whole_numbers_have_no_decimals() {
assert_eq!(format_number(1e15, None), "1000000000000000");
assert_eq!(format_number(2.5e15, None), "2500000000000000");
assert_eq!(format_number(-1e15, None), "-1000000000000000");
assert_eq!(format_number(9.0e18, None), "9000000000000000000");
assert_eq!(format_number(1e15 + 0.5, None), "1000000000000000.50");
}
}
+21 -333
View File
@@ -41,24 +41,6 @@ impl Math {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Rough heap bytes a clone copies: every node, its argument lists and its run text.
/// Iterative, so it costs at most the size of the structure.
pub(crate) fn heap_bytes(&self) -> usize {
let mut n = self.omml.len();
let mut todo: Vec<&MNode> = self.nodes.iter().collect();
while let Some(node) = todo.pop() {
n = n.saturating_add(std::mem::size_of::<MNode>());
if let MNode::Run(r) = node {
n = n.saturating_add(r.text.len()).saturating_add(r.font.as_ref().map_or(0, String::len));
}
each_arg(node, &mut |a| {
n = n.saturating_add(std::mem::size_of::<Arg>());
todo.extend(a);
});
}
n
}
}
/// Display equation justification (`m:oMathParaPr/m:jc`).
@@ -358,7 +340,7 @@ fn yes() -> bool {
/// A row holds an equation-number mark (`#`) in a top-level run.
pub fn has_number_mark(row: &[MNode]) -> bool {
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && !r.nor && r.text.contains('#')))
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && r.text.contains('#')))
}
/// Integral signs (their limits default to sub/sup positions).
@@ -488,27 +470,8 @@ fn lin_arg(a: &[MNode], s: &mut String, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (i, n) in a.iter().enumerate() {
for n in a {
lin_node(n, s, depth + 1);
// A bare script or limit ends at a space: a letter, digit or period after it is its own item.
if matches!(n, MNode::Script { kind, .. } if *kind != ScriptKind::Pre) || matches!(n, MNode::Lim { .. }) {
let ends_bare = s.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.');
if ends_bare && a.get(i + 1).and_then(first_lin_char).is_some_and(|c| c.is_alphanumeric() || c == '.') {
s.push(' ');
}
}
}
}
/// The first character `n` is written with, when that is cheap to tell (letters and digits).
fn first_lin_char(n: &MNode) -> Option<char> {
match n {
MNode::Run(r) => r.text.chars().next(),
MNode::Func { name, .. } => name.first().and_then(first_lin_char),
MNode::Script { kind, base, .. } if *kind != ScriptKind::Pre => base.first().and_then(first_lin_char),
MNode::Lim { e, .. } => e.first().and_then(first_lin_char),
MNode::Frac { num, .. } => num.first().and_then(first_lin_char),
_ => None,
}
}
@@ -536,27 +499,6 @@ fn lin_operand(a: &[MNode], s: &mut String, depth: usize) {
}
}
/// A script or limit operand. The parser reads a run of letters and digits up to the next space or
/// operator, so `V_dd` is written bare as Word's Linear format shows it (`V_(dd)` reads back the same).
fn lin_script(a: &[MNode], s: &mut String, depth: usize) {
// Bare only when the parser reads the whole run back, in the same style.
if let [MNode::Run(r)] = a
&& r.text.chars().nth(1).is_some()
{
let upright_name = r.sty == Some(MSty::Plain) && crate::math_linear::FUNCTION_NAMES.contains(&r.text.as_str());
let number = r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_alphanumeric() || c == '.');
let word = r.text.starts_with(char::is_alphabetic) && r.text.chars().all(char::is_alphanumeric);
let upright_other = r.sty == Some(MSty::Plain) && !upright_name;
if upright_other || (!number && !word) {
s.push('(');
s.push_str(&r.text);
s.push(')');
return;
}
}
lin_operand(a, s, depth);
}
fn lin_node(n: &MNode, s: &mut String, depth: usize) {
match n {
MNode::Run(r) => s.push_str(&r.text),
@@ -573,9 +515,9 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
MNode::Script { kind, base, sub, sup } => {
if *kind == ScriptKind::Pre {
s.push('_');
lin_script(sub, s, depth);
lin_operand(sub, s, depth);
s.push('^');
lin_script(sup, s, depth);
lin_operand(sup, s, depth);
s.push('▒');
lin_operand(base, s, depth);
return;
@@ -583,11 +525,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
lin_operand(base, s, depth);
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
s.push('_');
lin_script(sub, s, depth);
lin_operand(sub, s, depth);
}
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
s.push('^');
lin_script(sup, s, depth);
lin_operand(sup, s, depth);
}
}
MNode::Rad { deg, deg_hide, e } => {
@@ -606,11 +548,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
s.push(*chr);
if !sub_hide && !sub.is_empty() {
s.push('_');
lin_script(sub, s, depth);
lin_operand(sub, s, depth);
}
if !sup_hide && !sup.is_empty() {
s.push('^');
lin_script(sup, s, depth);
lin_operand(sup, s, depth);
}
s.push('▒');
lin_operand(e, s, depth);
@@ -650,7 +592,7 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
MNode::Lim { upper, e, lim } => {
lin_operand(e, s, depth);
s.push(if *upper { '┴' } else { '┬' });
lin_script(lim, s, depth);
lin_operand(lim, s, depth);
}
MNode::Acc { chr, e } => {
lin_operand(e, s, depth);
@@ -759,7 +701,7 @@ pub fn math_class(c: char) -> MClass {
}
}
/// Merge adjacent runs with the same formatting; recurse into every structure.
/// Merge adjacent runs with the same formatting; recurse into structures.
pub fn merge_runs(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
for n in a {
@@ -780,79 +722,23 @@ pub fn merge_runs(a: Arg) -> Arg {
}
MNode::Run(r)
}
other => crate::math_linear::map_args(other, &merge_runs),
MNode::Frac { kind, num, den } => MNode::Frac { kind, num: merge_runs(num), den: merge_runs(den) },
MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: merge_runs(base), sub: merge_runs(sub), sup: merge_runs(sup) },
MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: merge_runs(deg), deg_hide, e: merge_runs(e) },
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: merge_runs(sub), sup: merge_runs(sup), e: merge_runs(e) }
}
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(merge_runs).collect() }
}
MNode::Func { name, e } => MNode::Func { name: merge_runs(name), e: merge_runs(e) },
other => other,
};
out.push(n);
}
out
}
/// Deepest node (a leaf counts 1) the parsers wrap further. Half of [`MAX_DEPTH`]: serde_json
/// stops reading at 128 nested containers and a node costs two (object and array).
pub(crate) const WRAP_DEPTH: usize = MAX_DEPTH / 2;
/// Calls `f` with every argument of `n`.
fn each_arg<'a>(n: &'a MNode, f: &mut impl FnMut(&'a [MNode])) {
match n {
MNode::Run(_) => {}
MNode::Frac { num, den, .. } => {
f(num);
f(den);
}
MNode::Script { base, sub, sup, .. } => {
f(base);
f(sub);
f(sup);
}
MNode::Rad { deg, e, .. } => {
f(deg);
f(e);
}
MNode::Nary { sub, sup, e, .. } => {
f(sub);
f(sup);
f(e);
}
MNode::Delim { elems, .. } => elems.iter().for_each(|a| f(a)),
MNode::Func { name, e } => {
f(name);
f(e);
}
MNode::Lim { e, lim, .. } => {
f(e);
f(lim);
}
MNode::Acc { e, .. }
| MNode::Bar { e, .. }
| MNode::BorderBox { e, .. }
| MNode::Boxed { e }
| MNode::GroupChr { e, .. }
| MNode::Phant { e, .. } => f(e),
MNode::EqArr { rows } => rows.iter().for_each(|a| f(a)),
MNode::Matrix { rows, .. } => rows.iter().flatten().for_each(|a| f(a)),
}
}
/// `n` is already nested too deeply to be wrapped in another structure. The parsers use this so
/// `x^1^1^1…` stays shallow (a left-nested tree 10 000 deep overflows the stack in `Clone`,
/// `Drop`, `PartialEq` and serde). Iterative, stops at the first too-deep path; costs at most the
/// size of `n`, which the parsers' input caps bound.
pub(crate) fn too_deep(n: &MNode) -> bool {
let mut stack: Vec<(&MNode, usize)> = vec![(n, 1)];
while let Some((n, d)) = stack.pop() {
if d >= WRAP_DEPTH {
return true;
}
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
}
false
}
/// Any of `a` is too deep to be wrapped ([`too_deep`]).
pub(crate) fn any_too_deep(a: &[MNode]) -> bool {
a.iter().any(too_deep)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -921,188 +807,6 @@ mod tests {
}
}
//T1
/// Deepest nesting of `a` (a leaf counts 1), without recursion.
fn depth_of(a: &[MNode]) -> usize {
let mut best = 0;
let mut stack: Vec<(&MNode, usize)> = a.iter().map(|n| (n, 1)).collect();
while let Some((n, d)) = stack.pop() {
best = best.max(d);
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
}
best
}
/// Run `f` on a thread with a stack far below the 8 MB of a main thread (#1456).
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(512 * 1024).spawn(f).unwrap().join().unwrap();
}
#[test]
fn deeply_chained_input_stays_shallow() {
on_small_stack(|| {
let cases: Vec<(&str, Arg)> = vec![
("x^1^1…", parse_linear(&format!("x{}", "^1".repeat(9999)))),
("a/a/a…", parse_linear(&format!("a{}", "/a".repeat(9999)))),
("accents", parse_linear(&format!("x{}", "\u{302}".repeat(9999)))),
("limits", parse_linear(&format!("x{}", "┬a".repeat(9000)))),
("(a b)^1^1…", parse_linear(&format!("(a b){}", "^1".repeat(9000)))),
("latex x^1^1…", crate::math_latex::parse_latex(&format!("x{}", "^1".repeat(9999)))),
("latex \\sum…", crate::math_latex::parse_latex(&"\\sum".repeat(5000))),
("latex \\sin x^1…", crate::math_latex::parse_latex(&format!("\\sin x{}", "^1".repeat(9000)))),
];
for (name, n) in cases {
assert!(!n.is_empty(), "{name}");
assert!(depth_of(&n) <= 70, "{name}: depth {}", depth_of(&n));
let copy = n.clone();
assert_eq!(copy, n, "{name}");
let _ = to_linear(&n);
let _ = crate::math_latex::to_latex(&n);
let json = serde_json::to_string(&n).unwrap();
let back: Arg = serde_json::from_str(&json).unwrap();
assert_eq!(back, n, "{name}");
}
});
}
#[test]
fn ordinary_nesting_is_still_built() {
// Well below the cap: scripts, fractions and operators still wrap.
let n = parse_linear(&format!("x{}", "^1".repeat(10)));
assert_eq!(depth_of(&n), 11, "{n:#?}");
let n = crate::math_latex::parse_latex(r"\sum_{i}\sum_{j}a_{ij}");
assert!(matches!(&n[0], MNode::Nary { e, .. } if matches!(e.first(), Some(MNode::Nary { .. }))), "{n:#?}");
}
//T2
#[test]
fn latex_text_escapes_what_latex_reads_as_markup() {
for text in ["a}b\\c", "{x}", "50% of #1", "a_b&c$d~e^f", "\\", "\\alpha", "\\{"] {
let n = vec![MNode::Run(MRun { text: text.into(), nor: true, sty: Some(MSty::Plain), ..Default::default() })];
let tex = crate::math_latex::to_latex(&n);
assert_eq!(crate::math_latex::parse_latex(&tex), n, "{text:?} → {tex}");
}
let n = vec![MNode::Run(MRun::new("a\\b"))];
assert_eq!(crate::math_latex::parse_latex(&crate::math_latex::to_latex(&n)), n);
}
//T3
#[test]
fn latex_runs_merge_inside_every_structure() {
use crate::math_latex::parse_latex;
for (tex, lin) in [(r"\overline{ab}", "¯(ab)"), (r"\underline{ab}", "▁(ab)"), (r"\boxed{ab}", "▭(ab)"), (r"\phantom{ab}", "⟡(ab)")] {
assert_eq!(parse_latex(tex), parse_linear(lin), "{tex}");
}
let n = parse_latex(r"\begin{matrix}a b&c d\\e f&g\end{matrix}");
let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") };
assert!(rows.iter().flatten().all(|c| c.len() == 1), "{rows:#?}");
let n = parse_latex(r"\hat{ab}\underbrace{ab}\underset{ab}{cd}");
let runs = |a: &Arg| a.iter().filter(|n| matches!(n, MNode::Run(_))).count();
assert!(matches!(&n[0], MNode::Acc { e, .. } if runs(e) == 1), "{n:#?}");
assert!(matches!(&n[1], MNode::GroupChr { e, .. } if runs(e) == 1), "{n:#?}");
assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}");
}
#[test]
fn multi_letter_scripts_round_trip() {
// Word for Mac 16.113.4 (#1609): a script is the whole run up to a space or operator, and Word's
// Linear format shows it bare (`V_dd`); the parenthesised form reads back identically.
let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) };
for n in [
vec![script(ScriptKind::Sub, "dd", "")],
vec![script(ScriptKind::Sup, "", "ab")],
vec![script(ScriptKind::SubSup, "in", "2")],
vec![MNode::Nary {
chr: '∑',
lim_loc: None,
grow: false,
sub_hide: false,
sup_hide: false,
sub: vec![run("ij")],
sup: vec![run("n")],
e: vec![run("x")],
}],
vec![MNode::Lim { upper: false, e: vec![run("⟶")], lim: vec![run("yields")] }],
] {
assert_eq!(parse_linear(&to_linear(&n)), n, "{}", to_linear(&n));
}
// Numbers and single letters stay bare.
assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5");
assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d");
assert_eq!(to_linear(&[script(ScriptKind::Sub, "dd", "")]), "V_dd");
assert_eq!(to_linear(&[script(ScriptKind::Sup, "", "ab")]), "V^ab");
}
#[test]
fn multi_letter_script_operands_follow_word() {
// #1609, Word for Mac 16.113.4: `V_dd` and `V_(dd)` build up the same; `dd` is all subscript.
let sub_dd = MNode::Script { kind: ScriptKind::Sub, base: vec![run("V")], sub: vec![run("dd")], sup: vec![] };
assert_eq!(parse_linear("V_dd"), vec![sub_dd.clone()]);
assert_eq!(parse_linear("V_(dd)"), vec![sub_dd]);
let sup_ab = MNode::Script { kind: ScriptKind::Sup, base: vec![run("x")], sub: vec![], sup: vec![run("ab")] };
assert_eq!(parse_linear("x^ab"), vec![sup_ab.clone()]);
assert_eq!(parse_linear("x^(ab)"), vec![sup_ab]);
// Delimiters still end the operand.
let both = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("i")], sup: vec![run("2")] };
assert_eq!(parse_linear("x_i^2"), vec![both]);
let a_i = MNode::Script { kind: ScriptKind::Sub, base: vec![run("a")], sub: vec![run("i")], sup: vec![] };
assert_eq!(parse_linear("a_i b"), vec![a_i, run("b")]);
let both2 = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("ij")], sup: vec![run("kl")] };
assert_eq!(parse_linear("x_ij^kl"), vec![both2]);
// Round trip is Word's bare form.
assert_eq!(to_linear(&parse_linear("V_(dd)")), "V_dd");
}
#[test]
fn a_bare_script_does_not_absorb_what_follows() {
// #1609 review: `x_i` then `y` must not read back as x with subscript `iy`.
let sc =
|kind, base: &str, sub: &str, sup: &str| MNode::Script { kind, base: vec![run(base)], sub: parse_linear(sub), sup: parse_linear(sup) };
let sin_x = MNode::Func { name: vec![MNode::Run(MRun::plain("sin"))], e: vec![run("x")] };
let lim = MNode::Lim { upper: false, e: vec![run("a")], lim: vec![run("n")] };
for n in [
vec![sc(ScriptKind::Sub, "x", "i", ""), run("y")],
vec![sc(ScriptKind::Sub, "a", "i", ""), sc(ScriptKind::Sub, "b", "j", "")],
vec![sc(ScriptKind::Sup, "x", "", "2"), run("y")],
vec![sc(ScriptKind::Sup, "x", "", "2"), run("3")],
vec![sc(ScriptKind::Sub, "x", "i", ""), sin_x],
vec![lim.clone(), run("y")],
vec![sc(ScriptKind::Sub, "x", "ab", ""), run("y")],
vec![sc(ScriptKind::Sub, "x", "i", ""), run(".5")],
] {
let lin = to_linear(&n);
assert_eq!(parse_linear(&lin), n, "{lin}");
}
assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("y")]), "x_i y");
// Nothing extra when the script already ends in a bracket or a symbol follows.
assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("+")]), "x_i+");
}
#[test]
fn a_function_name_in_a_script_stays_upright() {
// Word for Mac (#1609): `x_max` and `x_(max)` both give an upright function name.
let up = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("max"))], sup: vec![] }];
assert_eq!(parse_linear("x_max"), up);
assert_eq!(parse_linear("x_(max)"), up);
assert_eq!(parse_linear(&to_linear(&up)), up, "{}", to_linear(&up));
// Other upright text is not a function name: it keeps its parentheses.
let other = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("ab"))], sup: vec![] }];
assert_eq!(to_linear(&other), "x_(ab)");
}
#[test]
fn digits_then_letters_continue_a_script() {
// Word for Mac (#1609): a script runs to a space or operator, so `x_2a` is subscript "2a".
let sub = |t: &str| vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![run(t)], sup: vec![] }];
assert_eq!(parse_linear("x_2a"), sub("2a"));
assert_eq!(parse_linear("x_a2"), sub("a2"));
assert_eq!(parse_linear("x_αβ"), sub("αβ"));
assert_eq!(parse_linear("x_10"), sub("10"));
assert_eq!(parse_linear("x_2.5"), sub("2.5"));
assert_eq!(to_linear(&sub("2a")), "x_2a");
assert_eq!(parse_linear(&to_linear(&sub("2a"))), sub("2a"));
}
#[test]
fn hostile_linear_never_panics() {
for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] {
@@ -1113,20 +817,4 @@ mod tests {
let deep = "√".repeat(3000);
let _ = parse_linear(&deep);
}
#[test]
fn a_long_run_of_letters_parses_in_linear_time() {
// Each letter that doesn't start a function name used to rescan (and copy) the rest of
// the run, so a 20,000-letter equation did 200 million steps. (No wall-clock assert: a
// loaded runner would fail it; a regression shows as a slow test.)
for _ in 0..5 {
let e = parse_linear(&"x".repeat(30_000));
assert!(matches!(e.as_slice(), [MNode::Run(r)] if r.text.len() == 20_000), "{:?}", e.len());
}
// Function names are still found at the start of a run, and only as whole runs.
assert!(matches!(parse_linear("sin x").as_slice(), [MNode::Func { .. }]));
assert!(matches!(parse_linear("arcsin x").as_slice(), [MNode::Func { .. }]));
assert!(matches!(parse_linear("sinx").as_slice(), [MNode::Run(_)]));
assert!(matches!(parse_linear("arcsinhx").as_slice(), [MNode::Run(_)]));
}
}
-35
View File
@@ -362,26 +362,6 @@ pub fn tidy(root: &mut Arg) {
go(root, 0);
}
/// Call `f` on every run in the tree.
pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) {
fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in a.iter_mut() {
if let MNode::Run(r) = n {
f(r);
}
for c in 0..child_count(n) {
if let Some(ch) = child_mut(n, c) {
go(ch, f, depth + 1);
}
}
}
}
go(root, f, 0);
}
fn merge_shallow(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
for n in a {
@@ -1199,21 +1179,6 @@ mod tests {
assert!(matches!(eq[0], MNode::Frac { .. }));
}
#[test]
fn build_up_keeps_a_multi_letter_subscript() {
// #1609: `dd` typed in a subscript box split into V_d and d on the next space.
let mut eq: Arg = Vec::new();
let p = insert_nodes(&mut eq, &MathPos::default(), parse_linear("⬚_⬚"));
let p = insert_text(&mut eq, &p, "V", TypeStyle::default());
let p = move_right(&eq, &p).unwrap();
let p = insert_text(&mut eq, &p, "dd", TypeStyle::default());
let p = move_right(&eq, &p).unwrap();
assert_eq!(p, MathPos::new(vec![], 1));
let built = eq.clone();
assert_eq!(build_up(&mut eq, &p, false), None);
assert_eq!(eq, built, "{}", lin(&eq));
}
#[test]
fn backspace_dissolves_and_removes() {
let mut eq = parse_linear("√x");
+27 -267
View File
@@ -5,9 +5,7 @@
//! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names,
//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text.
use crate::math::{
Arg, ColJc, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
};
use crate::math::{Arg, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, is_nary_char, math_class, merge_runs};
use crate::math_linear::{FUNCTION_NAMES, map_args};
use crate::math_symbols::{name_of, symbol};
@@ -23,31 +21,16 @@ enum Tok {
Ch(char),
}
/// Commands whose `{…}` argument is text, where spaces are part of the content.
const TEXT_COMMANDS: &[&str] = &["text", "textrm", "mbox", "textnormal", "textbf", "textit"];
fn tokenize(s: &str) -> Vec<Tok> {
let cs: Vec<char> = s.chars().take(20_000).collect();
let mut out = Vec::new();
let mut i = 0;
// A text command was just read: its `{` starts text, where spaces count (#448).
let mut text_next = false;
// Brace depth inside a text argument.
let mut text_depth: Option<u32> = None;
while let Some(&c) = cs.get(i) {
i += 1;
if text_depth.is_some() && matches!(c, ' ' | '\t' | '\n' | '\r') {
// A run of white space is one space, as in LaTeX.
if out.last() != Some(&Tok::Ch(' ')) {
out.push(Tok::Ch(' '));
}
continue;
}
let was_text_next = std::mem::take(&mut text_next);
match c {
'\\' => {
let start = i;
while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
while cs.get(i).is_some_and(|c| c.is_ascii_alphabetic()) {
i += 1;
}
if i == start {
@@ -63,31 +46,15 @@ fn tokenize(s: &str) -> Vec<Tok> {
None => {}
}
} else {
let name: String = cs.get(start..i).unwrap_or(&[]).iter().collect();
text_next = text_depth.is_none() && TEXT_COMMANDS.contains(&name.as_str());
out.push(Tok::Cmd(name));
out.push(Tok::Cmd(cs.get(start..i).unwrap_or(&[]).iter().collect()));
}
}
'{' => {
text_depth = match text_depth {
Some(d) => Some(d.saturating_add(1)),
None if was_text_next => Some(0),
None => None,
};
out.push(Tok::Open);
}
'}' => {
text_depth = match text_depth {
Some(0) | None => None,
Some(d) => Some(d.saturating_sub(1)),
};
out.push(Tok::Close);
}
'{' => out.push(Tok::Open),
'}' => out.push(Tok::Close),
'^' => out.push(Tok::Sup),
'_' => out.push(Tok::Sub),
'&' => out.push(Tok::Amp),
// White space between a text command and its `{` doesn't end the wait.
' ' | '\t' | '\n' | '\r' => text_next = was_text_next,
' ' | '\t' | '\n' | '\r' => {}
'~' => out.push(Tok::Ch('\u{A0}')),
c => out.push(Tok::Ch(c)),
}
@@ -111,7 +78,7 @@ pub fn parse_latex(s: &str) -> Arg {
p.i += 1;
}
let out = merge_runs(out);
if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) {
if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) {
return vec![MNode::EqArr { rows: vec![out] }];
}
out
@@ -149,13 +116,8 @@ impl Lp {
Tok::Sup | Tok::Sub => {
self.i += 1;
let s = self.arg();
if out.last().is_some_and(too_deep) {
// Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept.
out.extend(s);
} else {
let base = out.pop();
out.push(script(base, tok == Tok::Sup, s));
}
let base = out.pop();
out.push(script(base, tok == Tok::Sup, s));
}
Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => {
self.i += 1;
@@ -178,7 +140,7 @@ impl Lp {
}
self.depth -= 1;
// Big operators take the following term as their operand.
nary_operands(out, self.depth)
nary_operands(out)
}
/// A command argument: a `{group}` or one token.
@@ -231,14 +193,10 @@ impl Lp {
}
self.i += 1;
let mut level = 0i32;
// The previous token was a control word (`\backslash{}`: the empty braces end its name).
let mut after_word = false;
while let Some(t) = self.peek().cloned() {
self.i += 1;
let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic())));
match t {
Tok::Close if level == 0 => break,
Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1,
Tok::Open => {
level += 1;
s.push('{');
@@ -324,21 +282,6 @@ impl Lp {
let den = self.arg();
one(MNode::Frac { kind: FracKind::Bar, num, den })
}
"genfrac" => {
let beg = delim_arg(&self.arg());
let end = delim_arg(&self.arg());
let thickness = self.arg();
let _style = self.arg();
let num = self.arg();
let den = self.arg();
let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
let frac = MNode::Frac { kind, num, den };
if beg.is_none() && end.is_none() {
one(frac)
} else {
one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
}
}
"binom" | "dbinom" | "tbinom" => {
let n = self.arg();
let k = self.arg();
@@ -395,30 +338,9 @@ impl Lp {
one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() }))
}
"operatorname" => {
let starred = self.peek() == Some(&Tok::Ch('*'));
if starred {
self.i += 1;
}
let t = self.raw_arg();
let name = vec![MNode::Run(MRun::plain(t))];
if starred && self.peek() == Some(&Tok::Sub) {
self.i += 1;
let lim = self.arg();
vec![MNode::Lim { upper: false, e: name, lim }]
} else {
self.apply(name)
}
}
"tag" => {
let starred = self.peek() == Some(&Tok::Ch('*'));
if starred {
self.i += 1;
}
let t = self.raw_arg();
let num_str = if starred { format!("#{t}") } else { format!("# ({t})") };
let mut r = MRun::new(num_str);
r.lit = false;
vec![MNode::Run(r)]
self.apply(name)
}
"mathrm" | "rm" => {
let a = self.arg();
@@ -460,15 +382,13 @@ impl Lp {
"underline" => one(MNode::Bar { top: false, e: self.arg() }),
"boxed" | "fbox" => one(MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.arg() }),
"phantom" => one(MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.arg() }),
"overbrace" | "underbrace" | "overparen" | "underparen" | "overbracket" | "underbracket" => {
"overbrace" | "underbrace" | "overparen" | "underparen" => {
let top = c.starts_with("over");
let chr = match c {
"overbrace" => '⏞',
"underbrace" => '⏟',
"overparen" => '⏜',
"underparen" => '⏝',
"overbracket" => '⎴',
_ => '⎵',
_ => '⏝',
};
let e = self.arg();
let g = MNode::GroupChr { chr, top, e };
@@ -564,10 +484,6 @@ impl Lp {
while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() {
self.i += 1;
let s = self.arg();
if any_too_deep(&e) {
e.extend(s);
continue;
}
let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) };
e.push(script(base, t == Tok::Sup, s));
}
@@ -578,22 +494,6 @@ impl Lp {
}
fn environment(&mut self, env: &str) -> Arg {
let base = env.trim_end_matches('*');
let mut col_jc = Vec::new();
if base == "array" || base == "tabular" {
let _ = self.opt();
let spec = self.raw_arg();
for ch in spec.chars() {
match ch {
'l' | 'L' => col_jc.push(ColJc::Left),
'r' | 'R' => col_jc.push(ColJc::Right),
'c' | 'C' => col_jc.push(ColJc::Center),
_ => {}
}
}
} else if base == "aligned" {
let _ = self.opt();
}
// Rows of `&`-separated cells up to `\end{…}`.
let mut rows: Vec<Vec<Arg>> = vec![vec![]];
loop {
@@ -621,6 +521,7 @@ impl Lp {
if rows.len() > 1 && rows.last().is_some_and(|r| r.iter().all(|c| c.is_empty())) {
rows.pop();
}
let base = env.trim_end_matches('*');
let delims = match base {
"pmatrix" => Some((Some('('), Some(')'))),
"bmatrix" => Some((Some('['), Some(']'))),
@@ -638,8 +539,8 @@ impl Lp {
elems: vec![vec![inner]],
};
match base {
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" | "tabular" => {
let m = MNode::Matrix { rows, col_jc };
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" => {
let m = MNode::Matrix { rows, col_jc: Vec::new() };
match delims {
Some((b, e)) => vec![wrap(m, b, e)],
None => vec![m],
@@ -697,14 +598,12 @@ fn script(base: Option<MNode>, sup: bool, s: Arg) -> MNode {
}
/// Big operators without an operand take the atoms after them up to a relation or binary operator.
/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand
/// (`\sum\sum\sum…` must not nest once per operator).
fn nary_operands(a: Arg, depth: usize) -> Arg {
fn nary_operands(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
let mut it = a.into_iter().peekable();
while let Some(n) = it.next() {
match n {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() => {
let mut body = Vec::new();
while let Some(next) = it.peek() {
let stop = match next {
@@ -720,14 +619,8 @@ fn nary_operands(a: Arg, depth: usize) -> Arg {
body.push(x);
}
}
let body = merge_runs(nary_operands(body, depth + 1));
if any_too_deep(&body) {
// No room to wrap the operand: it follows the operator.
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e });
out.extend(body);
} else {
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body });
}
let body = nary_operands(body);
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: merge_runs(body) });
}
other => out.push(other),
}
@@ -801,46 +694,6 @@ fn chars(text: &str, s: &mut String) {
}
}
/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped.
fn text_chars(text: &str, s: &mut String) {
for c in text.chars() {
match c {
'{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => {
s.push('\\');
s.push(c);
}
// `{}` ends the command name without adding a space (spaces count in text).
'\\' => s.push_str("\\backslash{}"),
c => s.push(c),
}
}
}
/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
fn delim_arg(a: &[MNode]) -> Option<char> {
let [MNode::Run(r)] = a else { return None };
let mut cs = r.text.chars();
match (cs.next(), cs.next()) {
(Some(c), None) if c != '.' => Some(c),
_ => None,
}
}
/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
/// thickness means the default rule and anything that is not a plain number and unit is not zero.
fn is_zero_length(a: &[MNode]) -> bool {
let mut text = String::new();
for n in a {
let MNode::Run(r) = n else { return false };
text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
}
let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let unit = &text[number.len()..];
!number.is_empty()
&& matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
&& number.parse::<f64>().is_ok_and(|v| v == 0.0)
}
fn delim_tex(c: Option<char>) -> String {
match c {
None => ".".into(),
@@ -852,17 +705,6 @@ fn delim_tex(c: Option<char>) -> String {
}
}
fn group_chr_tex(chr: char, top: bool) -> &'static str {
match chr {
'⏜' => "\\overparen",
'⏝' => "\\underparen",
'⎴' => "\\overbracket",
'⎵' => "\\underbracket",
_ if top => "\\overbrace",
_ => "\\underbrace",
}
}
fn node(n: &MNode, s: &mut String, depth: usize) {
match n {
MNode::Run(r) => {
@@ -891,7 +733,7 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
};
s.push_str(open);
if r.nor {
text_chars(&r.text, s);
s.push_str(&r.text);
} else {
chars(&r.text, s);
}
@@ -966,16 +808,12 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
s.push(' ');
braced(e, s, depth);
}
MNode::Delim { beg, end, sep, elems, .. } => {
MNode::Delim { beg, end, elems, .. } => {
s.push_str("\\left");
s.push_str(&delim_tex(*beg));
let mid = match sep {
Some(c) => format!("\\middle{}", delim_tex(Some(*c))),
None => "\\middle|".to_string(),
};
for (i, e) in elems.iter().enumerate() {
if i > 0 {
s.push_str(&mid);
s.push_str("\\middle|");
}
arg(e, s, depth);
}
@@ -994,10 +832,11 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
s.push('_');
braced(lim, s, depth);
} else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() {
s.push_str(group_chr_tex(*chr, *top));
s.push_str(if *top { "\\overbrace" } else { "\\underbrace" });
braced(inner, s, depth);
s.push(if *upper { '^' } else { '_' });
braced(lim, s, depth);
let _ = chr;
} else {
s.push_str(if *upper { "\\overset" } else { "\\underset" });
braced(lim, s, depth);
@@ -1033,8 +872,8 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
braced(e, s, depth);
}
MNode::Boxed { e } => braced(e, s, depth),
MNode::GroupChr { chr, top, e } => {
s.push_str(group_chr_tex(*chr, *top));
MNode::GroupChr { top, e, .. } => {
s.push_str(if *top { "\\overbrace" } else { "\\underbrace" });
braced(e, s, depth);
}
MNode::EqArr { rows } => {
@@ -1116,34 +955,6 @@ mod tests {
let _ = (LimLoc::UndOvr, MathJc::Center);
}
#[test]
fn latex_binom_round_trip() {
let a = parse_latex(r"\binom{n}{k}");
let l = to_latex(&a);
assert_eq!(parse_latex(&l), a, "exported as {l}");
let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
assert_eq!(b, vec![nobar("n", "k")]);
assert_eq!(parse_latex(&to_latex(&b)), b);
// Other spellings of a zero thickness.
for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
}
// A non-zero or default thickness keeps the bar.
for t in ["1pt", "0.4pt", ""] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
assert_eq!(c, vec![bar], "thickness {t:?}");
}
// Delimiters are kept.
let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
assert_eq!(d, a);
let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
assert_eq!(parse_latex(&to_latex(&e)), e);
}
#[test]
fn latex_round_trip() {
for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sin⁡x+cos⁡y", "e^(-x^2)"] {
@@ -1154,15 +965,6 @@ mod tests {
}
}
#[test]
fn group_character_latex_roundtrip() {
for cmd in ["\\overparen{a+b}", "\\underparen{a+b}", "\\overbracket{x}", "\\underbracket{x}"] {
let n = parse_latex(cmd);
let tex = to_latex(&n);
assert_eq!(tex, cmd);
}
}
#[test]
fn hostile_latex_never_panics() {
for s in ["", "\\", "{", "}", "^", "_", "\\frac", "\\sqrt[", "\\left(", "\\right)", "\\begin{matrix", "&&\\\\", "{{{{", "\\end{x}", "a^^b__c"]
@@ -1178,46 +980,4 @@ mod tests {
let deep = "\\hat".repeat(5000) + "x";
let _ = parse_latex(&deep);
}
#[test]
fn array_column_specs_and_aligned_optional_args() {
let n = parse_latex(r"\begin{array}{lcr} a & b & c \\ d & e & f \end{array}");
let MNode::Matrix { rows, col_jc } = &n[0] else { panic!("{n:#?}") };
assert_eq!(col_jc, &[ColJc::Left, ColJc::Center, ColJc::Right]);
assert_eq!(rows.len(), 2);
assert_eq!(crate::math::to_linear(&rows[0][0]), "a");
assert_eq!(crate::math::to_linear(&rows[0][1]), "b");
assert_eq!(crate::math::to_linear(&rows[0][2]), "c");
let n = parse_latex(r"\begin{aligned}[t] x &= 1 \\ y &= 2 \end{aligned}");
assert!(matches!(&n[0], MNode::EqArr { rows } if rows.len() == 2));
}
#[test]
fn operatorname_starred_and_tag() {
let n = parse_latex(r"\operatorname*{argmin}_{x} f(x)");
assert!(matches!(&n[0], MNode::Lim { upper: false, .. }));
let n = parse_latex(r"E=mc^2 \tag{1}");
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
assert!(crate::math::has_number_mark(&rows[0]));
let n = parse_latex(r"E=mc^2 \tag*{(A)}");
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
assert!(crate::math::has_number_mark(&rows[0]));
}
#[test]
fn delim_preserves_custom_separator() {
let delim = MNode::Delim {
beg: Some('{'),
end: Some('}'),
sep: Some('‖'),
grow: true,
shp_match: false,
elems: vec![vec![MNode::Run(MRun::new("x"))], vec![MNode::Run(MRun::new("y"))]],
};
let tex = to_latex(&[delim]);
assert!(tex.contains(r"\middle\|"), "Expected \\middle\\|, got: {tex}");
}
}
+23 -70
View File
@@ -9,7 +9,7 @@
//! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing
//! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`).
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep};
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, is_nary_char, math_class, merge_runs};
/// Function names set upright and applied to the following operand.
pub const FUNCTION_NAMES: &[&str] = &[
@@ -18,18 +18,6 @@ pub const FUNCTION_NAMES: &[&str] = &[
"tanh",
];
/// Length of the longest of [`FUNCTION_NAMES`].
const MAX_NAME: usize = {
let (mut max, mut i) = (0, 0);
while i < FUNCTION_NAMES.len() {
if FUNCTION_NAMES[i].len() > max {
max = FUNCTION_NAMES[i].len();
}
i += 1;
}
max
};
/// Function names whose subscripts go underneath (`lim_(n→∞)`).
const LIMIT_NAMES: &[&str] = &["lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "gcd", "Pr"];
@@ -147,11 +135,6 @@ impl P {
'/' | '⁄' | '∕' | '¦' => {
self.i += 1;
let start = operand_start(&out);
// A numerator that is already deep stays as it is: `/` is text then.
if out.get(start..).is_some_and(any_too_deep) {
out.push(text(c));
continue;
}
let num = strip_parens(out.drain(start..).collect());
let den = self.operand(stops);
let kind = match c {
@@ -173,34 +156,20 @@ impl P {
} else {
self.i += 1;
let s = self.script_operand(stops);
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(s);
} else {
let base = out.pop();
out.push(attach_script(base, c == '^', s));
}
let base = out.pop();
out.push(attach_script(base, c == '^', s));
}
}
'┬' | '┴' => {
self.i += 1;
let lim = self.script_operand(stops);
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(lim);
} else {
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
}
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
}
c if is_combining(c) => {
self.i += 1;
if out.last().is_some_and(too_deep) {
out.push(text(c));
} else {
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
out.push(MNode::Acc { chr: c, e });
}
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
out.push(MNode::Acc { chr: c, e });
}
'▒' | '\u{2061}' => self.i += 1,
' ' => {
@@ -351,7 +320,7 @@ impl P {
/// A prefix structure after its character.
fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
match c {
'√'..='∜' => {
'√' | '∛' | '∜' => {
let mut deg = match c {
'∛' => vec![text('3')],
'∜' => vec![text('4')],
@@ -422,12 +391,10 @@ impl P {
fn word(&mut self, stops: &[char]) -> Arg {
let start = self.i;
let mut end = start;
// Look no further than the longest name (and one past it, to see the run is longer): a
// scan to the end of every run made a long run of letters quadratic (CWE-407).
while end - start <= MAX_NAME && self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) {
end += 1;
}
let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() };
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
if !FUNCTION_NAMES.contains(&word.as_str()) {
self.i = start + 1;
return vec![text(self.c.get(start).copied().unwrap_or(' '))];
@@ -570,39 +537,31 @@ impl P {
}
out.pop()
};
if !(matches!(c, '^' | '_') || matches!(c, '┬' | '┴') && self.no_limits == 0 || is_combining(c)) {
break;
}
// The base is already deep: the operator is text (see `too_deep`).
let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) };
self.i += 1;
if deep {
let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() };
out.push(text(c));
out.extend(s);
continue;
}
match c {
'^' | '_' => {
self.i += 1;
let s = self.script_operand(stops);
let b = base(out);
out.push(attach_script(b, c == '^', s));
}
'┬' | '┴' => {
'┬' | '┴' if self.no_limits == 0 => {
self.i += 1;
let lim = self.script_operand(stops);
let b = base(out);
out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim });
}
_ => {
c if is_combining(c) => {
self.i += 1;
let b = base(out);
out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() });
}
_ => break,
}
}
}
/// A script or limit: a parenthesised group, a structure, a number, a signed operand, a run of
/// letters (and digits) or one other character.
/// A script or limit: a parenthesised group, a structure, a number, a signed operand or one
/// character.
fn script_operand(&mut self, stops: &[char]) -> Arg {
if self.tired() {
return self.rest();
@@ -626,7 +585,7 @@ impl P {
Some(c) if is_prefix(c) || closer(c).is_some() => self.atom(stops),
Some(c) if c.is_ascii_digit() => {
let start = self.i;
while self.peek().is_some_and(|k| k.is_alphanumeric() || k == '.') {
while self.peek().is_some_and(|k| k.is_ascii_digit() || k == '.') {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
@@ -638,16 +597,10 @@ impl P {
v.extend(self.script_operand(stops));
v
}
Some(c) if c.is_alphabetic() => {
// Word (#1609): the whole run of letters and digits up to a space or operator, so
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
let start = self.i;
while self.peek().is_some_and(char::is_alphanumeric) {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
// A known function name stays upright, as Word builds it.
vec![MNode::Run(if FUNCTION_NAMES.contains(&t.as_str()) { MRun::plain(t) } else { MRun::new(t) })]
Some(c) if c.is_ascii_alphabetic() => {
// A function name as a script (`e^sin x` is rare): one letter.
self.i += 1;
vec![text(c)]
}
Some(c) if !stops.contains(&c) && c != ' ' && !is_closer(c) => {
self.i += 1;
-1
View File
@@ -85,7 +85,6 @@ pub const SYMBOLS: &[(&str, char)] = &[
("vee", '∨'),
("wedge", '∧'),
("setminus", '∖'),
("backslash", '\\'),
("wr", '≀'),
("diamond", '⋄'),
("dagger", '†'),
+29 -261
View File
@@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize};
use crate::props::{Align, CharProps};
use crate::section::NumFormat;
use crate::{DocError, free_id};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -35,15 +34,6 @@ pub struct Level {
pub chr: CharProps,
/// Restart after a higher level (true = after any higher level, the default).
pub restart: bool,
/// Restart only after this level (1-based, Word's `w:lvlRestart`): a level 3 that restarts
/// after level 1 keeps counting across level 2 items. `None` restarts after the level just
/// above. Ignored when `restart` is false.
#[serde(skip_serializing_if = "Option::is_none")]
pub restart_after: Option<u8>,
/// The tab stop that follows the number (Word's "Add tab stop at"), points from the
/// paragraph's left edge.
#[serde(skip_serializing_if = "Option::is_none")]
pub tab: Option<f32>,
/// Show all levels as decimal (legal numbering).
pub legal: bool,
/// Paragraph style linked to this level (e.g. Heading 1 for outline numbering).
@@ -62,8 +52,6 @@ impl Default for Level {
suffix: LevelSuffix::Tab,
chr: CharProps::default(),
restart: true,
restart_after: None,
tab: None,
legal: false,
style: None,
}
@@ -76,17 +64,6 @@ pub struct AbstractNum {
pub id: u32,
pub name: Option<String>,
pub levels: Vec<Level>,
/// Word's list identity (`w:nsid`, 8 hex digits), which it uses to match lists across
/// documents (paste, compare); kept from the file.
#[serde(skip_serializing_if = "Option::is_none")]
pub nsid: Option<String>,
/// The list template the definition came from (`w:tmpl`); kept from the file.
#[serde(skip_serializing_if = "Option::is_none")]
pub tmpl: Option<String>,
/// `w:multiLevelType` from the file (`singleLevel`, `multilevel`, `hybridMultilevel`);
/// `None`: derived from the levels.
#[serde(skip_serializing_if = "Option::is_none")]
pub multi_level_type: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
@@ -156,11 +133,6 @@ impl ListKind {
}
}
/// The bullet a level of a plain bulleted list shows: • ○ ▪ repeating (`level` is zero-based).
pub fn default_bullet(level: usize) -> char {
['•', '○', '▪'][level % 3]
}
fn bullet_level(i: usize, c: char) -> Level {
Level {
format: NumFormat::Bullet,
@@ -193,8 +165,8 @@ pub fn levels_for(kind: ListKind) -> Vec<Level> {
let lvl = |format: NumFormat, text: String| Level { format, text, indent: ind, hanging: 18.0, ..Level::default() };
let n = i + 1;
match kind {
ListKind::Bullet => bullet_level(i, default_bullet(i)),
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { default_bullet(i) }),
ListKind::Bullet => bullet_level(i, ['•', '○', '▪'][i % 3]),
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { ['•', '○', '▪'][i % 3] }),
ListKind::Numbered => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
ListKind::NumberedParen => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n})")),
ListKind::UpperLetter => lvl(cyc(NumFormat::UpperLetter, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
@@ -225,51 +197,26 @@ impl Numbering {
let n = self.num(num)?;
self.abstracts.iter().find(|a| a.id == n.abstract_id)
}
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
/// one), without counting it.
pub fn shows_label(&self, num: u32, level: u8) -> bool {
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
}
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
return Some(l);
}
self.abstract_of(num)?.levels.get(level as usize)
}
/// Create a new list of `kind`; returns the `num` id. List ids run out only past `u32::MAX`
/// lists, more than memory holds; then nothing is added and the id is 0, "no list".
/// Create a new list of `kind`; returns the `num` id.
pub fn add_list(&mut self, kind: ListKind) -> u32 {
self.try_add_list(kind).unwrap_or(0)
}
/// [`Numbering::add_list`], or an error, adding nothing, when list ids are all taken.
pub fn try_add_list(&mut self, kind: ListKind) -> crate::Result<u32> {
self.try_add_levels(None, levels_for(kind))
}
/// A new list definition (`name`, `levels`) and a `num` for it; returns the `num` id. Both ids
/// are [`free_id`]s, so the largest ids a file can hold don't make them collide with its lists;
/// an error, adding nothing, when they are all taken.
pub fn try_add_levels(&mut self, name: Option<String>, levels: Vec<Level>) -> crate::Result<u32> {
let aid = free_id(self.abstracts.iter().map(|a| a.id).max(), self.abstracts.iter().map(|a| a.id), 0);
let (Some(aid), Some(nid)) = (aid, self.free_num_id()) else { return Err(DocError::Invalid("no free list id".into())) };
self.abstracts.push(AbstractNum { id: aid, name, levels, ..Default::default() });
let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind) });
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
Ok(nid)
nid
}
/// A `num` id no list has (`num` 0 means "no list"), `None` when they are all taken.
fn free_num_id(&self) -> Option<u32> {
free_id(self.nums.iter().map(|n| n.id).max(), self.nums.iter().map(|n| n.id), 1)
}
/// A new `num` that restarts numbering of an existing list (`None`: no such list, or list ids
/// are all taken).
/// A new `num` that restarts numbering of an existing list.
pub fn restart(&mut self, num: u32) -> Option<u32> {
let source = self.num(num)?;
let a = source.abstract_id;
let level_overrides = source.level_overrides.clone();
let nid = self.free_num_id()?;
// Every level starts again at its own effective start, so a level this list defines itself keeps its start.
let levels = self.abstracts.iter().find(|x| x.id == a).map_or(0, |x| x.levels.len());
let start_overrides = (0..levels).filter_map(|i| self.level(num, i as u8).map(|l| (i as u8, l.start))).collect();
self.nums.push(Num { id: nid, abstract_id: a, start_overrides, level_overrides });
let a = self.num(num)?.abstract_id;
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
let starts = self.abstracts.iter().find(|x| x.id == a).map(|x| x.levels.iter().enumerate().map(|(i, l)| (i as u8, l.start)).collect());
self.nums.push(Num { id: nid, abstract_id: a, start_overrides: starts.unwrap_or_default(), ..Default::default() });
Some(nid)
}
/// Find an existing list whose first level matches `kind`, so consecutive bullet clicks reuse it.
@@ -288,28 +235,11 @@ impl Numbering {
pub struct Counters {
/// Per abstract list: current value per level.
state: HashMap<u32, [u32; 9]>,
/// Which levels of which nums have started: a num's start override for a level applies
/// once, when that level of that num is first used (ECMA-376 Part 1, 17.9.27 startOverride).
seen: HashMap<(u32, u8), bool>,
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
/// level starts at 0).
flags: HashMap<u32, [LevelFlags; 9]>,
}
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
#[derive(Default, Debug, Clone, Copy)]
struct LevelFlags {
started: bool,
first: Option<u32>,
/// Which nums have started (to apply start overrides once).
seen: HashMap<u32, bool>,
}
impl Counters {
/// The current value of each level of `num`'s list (0 for a level not counted yet).
pub fn values(&self, numbering: &Numbering, num: u32) -> Option<[u32; 9]> {
let abs = numbering.abstract_of(num)?;
Some(self.state.get(&abs.id).copied().unwrap_or([0; 9]))
}
/// Advance the counter for `num`/`level` and return the label (`"1."`, `"a)"`, `"•"`).
pub fn next_label(&mut self, numbering: &Numbering, num: u32, level: u8) -> Option<(String, Level)> {
let n = numbering.num(num)?;
@@ -323,42 +253,29 @@ impl Counters {
return None;
}
let st = self.state.entry(abs.id).or_insert([0; 9]);
let fl = self.flags.entry(abs.id).or_default();
// Only this level's override, not the num's others: the counters are shared by every
// num of the abstract list, so restarting its other levels here would renumber
// paragraphs of the other nums. A heading style can use one num for levels 1-4 and
// another, with start overrides on every level, for 5-9; its first level-5 heading
// must not restart the chapter count.
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry((num, lv as u8)) {
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry(num) {
e.insert(true);
if let Some((_, start)) = n.start_overrides.iter().find(|(l, _)| *l as usize == lv)
&& let (Some(s), Some(f)) = (st.get_mut(lv), fl.get_mut(lv))
{
*s = 0;
*f = LevelFlags { started: false, first: Some(*start) };
for (l, start) in &n.start_overrides {
if let Some(s) = st.get_mut(*l as usize) {
*s = start.saturating_sub(1);
}
}
}
if let (Some(v), Some(f)) = (st.get_mut(lv), fl.get_mut(lv)) {
*v = if f.started { v.saturating_add(1) } else { f.first.unwrap_or(def.start) };
*f = LevelFlags { started: true, first: None };
}
// Deeper levels restart, unless they never restart or only restart after a level above
// this one (Word's lvlRestart).
for (d, (deeper, f)) in st.iter_mut().zip(fl.iter_mut()).enumerate().skip(lv + 1) {
let restarts = level_def(d).is_none_or(|l| l.restart && l.restart_after.is_none_or(|k| lv < k as usize));
if restarts {
*deeper = 0;
*f = LevelFlags::default();
if let Some(v) = st.get_mut(lv) {
*v = if *v == 0 { def.start.max(if def.start == 0 { 0 } else { 1 }) } else { v.saturating_add(1) };
if *v == 0 {
*v = def.start;
}
}
for deeper in st.iter_mut().skip(lv + 1) {
*deeper = 0;
}
let vals = *st;
let started = *fl;
if def.format == NumFormat::Bullet {
return Some((def.text.clone(), def));
}
let mut label = String::new();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
let mut chars = def.text.chars().peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
@@ -366,19 +283,9 @@ impl Counters {
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
let val = if val == 0 { level_def(k).map(|l| l.start).unwrap_or(1) } else { val };
let fmt = if def.legal && k != lv { NumFormat::Decimal } else { level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) };
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
@@ -387,29 +294,10 @@ impl Counters {
}
}
/// Longest level text (in characters) expanded into a list label.
const MAX_LEVEL_TEXT: usize = 64;
/// Labels stop growing once they pass this many bytes.
const MAX_LABEL_BYTES: usize = 256;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let huge = Level { format: NumFormat::UpperRoman, text: "%1".repeat(30_000), start: u32::MAX, ..Level::default() };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, huge)];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
assert!(label.len() <= 512, "label is {} bytes", label.len());
assert!(label.starts_with("MMMM"));
}
#[test]
fn a_list_s_own_level_definitions_win() {
// A specification's article numbers: level 1 counts parts with no label of its own, level 2
@@ -431,85 +319,6 @@ mod tests {
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
}
#[test]
fn a_start_override_restarts_only_its_own_level_of_a_shared_list() {
// One abstract list numbers the headings through two nums: one for levels 1-4, and one
// restarting every level (startOverride 1 on 0..8) for levels 5-9, first used deep in
// chapter 2. Chapter 3 is still chapter 3, and the deep heading counts from 1.
let mut n = Numbering::default();
let chapters = n.add_list(ListKind::Legal);
let abs = n.abstract_of(chapters).map(|a| a.id).unwrap();
let deep = chapters + 1;
n.nums.push(Num { id: deep, abstract_id: abs, start_overrides: (0..9).map(|l| (l, 1)).collect(), ..Num::default() });
let mut c = Counters::default();
let mut l = |num, lv| c.next_label(&n, num, lv).unwrap().0;
assert_eq!(l(chapters, 0), "1.");
assert_eq!(l(chapters, 0), "2.");
assert_eq!(l(chapters, 1), "2.1.");
assert_eq!(l(chapters, 2), "2.1.1.");
assert_eq!(l(chapters, 3), "2.1.1.1.");
assert_eq!(l(deep, 4), "2.1.1.1.1.", "the deep num restarts only level 5");
assert_eq!(l(deep, 4), "2.1.1.1.2.");
assert_eq!(l(chapters, 0), "3.", "and not the chapter count");
assert_eq!(l(chapters, 1), "3.1.");
}
#[test]
fn levels_restart_only_after_their_restart_level() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Legal);
if let Some(a) = n.abstracts.first_mut() {
// Level 3 restarts only after level 1; level 2 never restarts.
a.levels[2].restart_after = Some(1);
a.levels[2].text = "(%3)".into();
a.levels[1].restart = false;
}
let mut c = Counters::default();
let mut l = |lv| c.next_label(&n, id, lv).unwrap().0;
assert_eq!(l(0), "1.");
assert_eq!(l(1), "1.1.");
assert_eq!(l(2), "(1)");
assert_eq!(l(1), "1.2.");
assert_eq!(l(2), "(2)", "level 2 doesn't restart level 3");
assert_eq!(l(0), "2.");
assert_eq!(l(1), "2.3.", "level 2 never restarts");
assert_eq!(l(2), "(1)", "level 1 restarts level 3");
}
#[test]
fn a_level_starting_at_zero_counts_up() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(a) = n.abstracts.first_mut() {
a.levels[0].start = 0;
}
let mut c = Counters::default();
let labels: Vec<String> = (0..4).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
assert_eq!(labels, ["0.", "1.", "2.", "3."]);
}
#[test]
fn a_start_override_of_one_or_zero_is_honoured() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(a) = n.abstracts.first_mut() {
a.levels[0].start = 5;
}
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.start_overrides = vec![(0, 1)];
}
let mut c = Counters::default();
let labels: Vec<String> = (0..2).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
assert_eq!(labels, ["1.", "2."]);
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.start_overrides = vec![(0, 0)];
}
let mut c = Counters::default();
let labels: Vec<String> = (0..3).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
assert_eq!(labels, ["0.", "1.", "2."]);
}
#[test]
fn numbered_labels() {
let mut n = Numbering::default();
@@ -524,32 +333,6 @@ mod tests {
assert!(c.next_label(&n, 999, 0).is_none());
}
#[test]
fn east_asian_labels_and_legal_numbering() {
// A Chinese outline: 第一章, then "一、" items, and a legal level showing 1.1.
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(a) = n.abstracts.first_mut() {
a.levels[0] = Level { format: NumFormat::ChineseCounting, text: "第%1章".into(), ..Level::default() };
a.levels[1] = Level { format: NumFormat::ChineseCounting, text: "%2、".into(), ..Level::default() };
a.levels[2] = Level { format: NumFormat::ChineseCounting, text: "%1.%2.%3".into(), legal: true, ..Level::default() };
a.levels[3] = Level { format: NumFormat::ChineseCounting, text: "(%4)".into(), ..Level::default() };
}
let mut c = Counters::default();
let mut l = |lv| c.next_label(&n, id, lv).unwrap().0;
assert_eq!(l(0), "第一章");
assert_eq!(l(1), "一、");
assert_eq!(l(1), "二、");
assert_eq!(l(1), "三、");
assert_eq!(l(2), "1.3.1", "legal numbering is decimal on every level");
assert_eq!(l(3), "(一)");
for _ in 0..9 {
l(3);
}
assert_eq!(l(3), "(十一)");
assert_eq!(l(0), "第二章");
}
#[test]
fn legal_labels() {
let mut n = Numbering::default();
@@ -574,19 +357,4 @@ mod tests {
assert_eq!(c.next_label(&n, b, 0).unwrap().0, "•");
assert_eq!(n.find_kind(ListKind::Bullet), Some(b));
}
#[test]
fn restart_keeps_the_list_s_own_level_definitions() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let own = Level { format: NumFormat::UpperRoman, text: "Part %1".into(), indent: 90.0, hanging: 40.0, start: 5, ..Level::default() };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides.push((0, own.clone()));
}
let r = n.restart(id).unwrap();
assert_eq!(n.num(r).unwrap().level_overrides, vec![(0, own.clone())]);
assert_eq!(n.level(r, 0), Some(&own));
let mut c = Counters::default();
assert_eq!(c.next_label(&n, r, 0).unwrap().0, "Part V");
}
}
+44 -593
View File
@@ -58,54 +58,6 @@ pub enum Wrap {
InFrontOfText,
}
/// Which sides of a Square, Tight or Through object text may flow on (`@wrapText`, ECMA-376
/// Part 1, §20.4.3.7 `ST_WrapText`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum WrapText {
/// Both sides of the object.
#[default]
BothSides,
/// Only the left side: the band to the object's right stays empty.
Left,
/// Only the right side.
Right,
/// Only whichever side has more room, decided per line.
Largest,
}
impl WrapText {
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
pub fn from_ooxml(v: &str) -> Option<WrapText> {
let v = v.trim();
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
Some(WrapText::BothSides)
} else if v.eq_ignore_ascii_case("left") {
Some(WrapText::Left)
} else if v.eq_ignore_ascii_case("right") {
Some(WrapText::Right)
} else if v.eq_ignore_ascii_case("largest") {
Some(WrapText::Largest)
} else {
None
}
}
/// The OOXML value.
pub fn ooxml(self) -> &'static str {
match self {
WrapText::BothSides => "bothSides",
WrapText::Left => "left",
WrapText::Right => "right",
WrapText::Largest => "largest",
}
}
pub fn is_both(&self) -> bool {
*self == WrapText::BothSides
}
}
/// What a floating object's offset is relative to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -174,13 +126,10 @@ impl FloatAlign {
}
/// Floating placement (ignored when `wrap` is `Inline`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Float {
pub wrap: Wrap,
/// The sides text wraps on (Square, Tight and Through).
#[serde(skip_serializing_if = "WrapText::is_both")]
pub wrap_text: WrapText,
pub h_rel: Anchor,
pub v_rel: Anchor,
/// Offsets, points (used when the matching alignment is `None`).
@@ -197,100 +146,11 @@ pub struct Float {
pub dist_top: f32,
pub dist_bottom: f32,
/// Room around the object for effects such as shadows (left, top, right, bottom, points),
/// inline or floating: lines and surrounding text keep clear of it. Rotation's own overhang
/// is not in here: it follows from [`Float::rot`] (see [`Float::spin_pad`]).
/// inline or floating: lines and surrounding text keep clear of it.
pub effect: [f32; 4],
/// Rotation clockwise about the object's centre, degrees (DrawingML `a:xfrm/@rot`). Offsets
/// and the size stay those of the unrotated frame, as in Word; see [`Float::spin`].
#[serde(skip_serializing_if = "is_zero")]
pub rot: f32,
/// Mirrored left to right / top to bottom (`a:xfrm/@flipH`, `@flipV`).
#[serde(skip_serializing_if = "is_false")]
pub flip_h: bool,
#[serde(skip_serializing_if = "is_false")]
pub flip_v: bool,
/// The outline Tight and Through wrapping follow (`wp:wrapPolygon`), in the object's own
/// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub wrap_polygon: Option<std::sync::Arc<crate::wrap::WrapPolygon>>,
/// What the drawing is, for screen readers and exports (`wp:docPr` `@descr`, `@title` and the
/// decorative mark). See [`InlineObject::alt_text`].
#[serde(skip_serializing_if = "AltText::is_empty")]
pub alt: AltText,
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
#[serde(skip_serializing_if = "is_false")]
pub hidden: bool,
/// Anchored in a table cell but placed on the page, not in the cell (`wp:anchor/@layoutInCell`
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
#[serde(skip_serializing_if = "is_false")]
pub outside_cell: bool,
/// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
#[serde(skip_serializing_if = "is_zero_u32")]
pub z: u32,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
pub const MAX_ALT_TEXT: usize = 10_000;
/// A drawing's alternative text (Picture/Shape Format › Accessibility › Alt Text).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct AltText {
/// The description of a shape, text box or group. Pictures and charts keep theirs in their
/// own `alt`; read it with [`InlineObject::alt_text`], which knows where it is.
#[serde(skip_serializing_if = "String::is_empty")]
pub text: String,
/// The short title (`wp:docPr/@title`).
#[serde(skip_serializing_if = "String::is_empty")]
pub title: String,
/// Decorative: it adds nothing to the content, so screen readers skip it and the
/// accessibility checker doesn't ask for a description.
#[serde(skip_serializing_if = "is_false")]
pub decorative: bool,
}
impl AltText {
pub fn is_empty(&self) -> bool {
self.text.is_empty() && self.title.is_empty() && !self.decorative
}
}
/// `s` cut to [`MAX_ALT_TEXT`] characters.
pub fn cap_alt(s: &str) -> String {
s.chars().take(MAX_ALT_TEXT).collect()
}
fn is_zero(v: &f32) -> bool {
*v == 0.0
}
fn is_false(v: &bool) -> bool {
!*v
}
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
impl Float {
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
pub fn spin(&self) -> wordcraft_geom::Spin {
wordcraft_geom::Spin::new(self.rot, self.flip_h, self.flip_v)
}
/// Set the rotation and flips (the angle normalised).
pub fn set_spin(&mut self, s: wordcraft_geom::Spin) {
self.rot = wordcraft_geom::normalize_degrees(s.deg);
self.flip_h = s.flip_h;
self.flip_v = s.flip_v;
}
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
/// is narrower than its frame, as a wide picture turned 90° is).
pub fn spin_pad(&self, w: f32, h: f32) -> (f32, f32) {
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
let (bw, bh) = self.spin().extent(w, h);
((bw - w) / 2.0, (bh - h) / 2.0)
}
/// The effect extents, finite and clamped (left, top, right, bottom).
pub fn effect_extent(&self) -> [f32; 4] {
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
@@ -304,31 +164,14 @@ pub enum ShapeKind {
Rectangle,
RoundedRectangle,
Ellipse,
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
FlowchartOr,
Triangle,
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
RightTriangle,
Diamond,
Line,
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
Arrow,
/// A block arrow pointing left (`leftArrow`).
ArrowLeft,
/// A block arrow pointing up (`upArrow`).
ArrowUp,
/// A block arrow pointing down (`downArrow`).
ArrowDown,
Star,
Heart,
/// A text box (rectangle with a text story).
TextBox,
/// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`).
Freeform,
/// Connectors: lines whose ends can be glued to other shapes (see [`crate::connector`]).
StraightConnector,
ElbowConnector,
CurvedConnector,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
@@ -339,109 +182,6 @@ pub enum NoteKind {
Endnote,
}
/// Where a positional tab ([`InlineObject::PositionalTab`]) lands on its line, and how the text
/// after it lines up there (ECMA-376 `ST_PTabAlignment`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PTabAlign {
#[default]
Left,
Center,
Right,
}
impl PTabAlign {
pub const ALL: [PTabAlign; 3] = [PTabAlign::Left, PTabAlign::Center, PTabAlign::Right];
/// The OOXML name (`left`, `center`, `right`).
pub fn ooxml(self) -> &'static str {
match self {
PTabAlign::Left => "left",
PTabAlign::Center => "center",
PTabAlign::Right => "right",
}
}
pub fn from_ooxml(v: &str) -> Option<PTabAlign> {
PTabAlign::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// What a positional tab's place is measured against (ECMA-376 `ST_PTabRelativeTo`): the text
/// margins, or the paragraph's indents.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PTabBase {
#[default]
Margin,
Indent,
}
impl PTabBase {
pub const ALL: [PTabBase; 2] = [PTabBase::Margin, PTabBase::Indent];
/// The OOXML name (`margin`, `indent`).
pub fn ooxml(self) -> &'static str {
match self {
PTabBase::Margin => "margin",
PTabBase::Indent => "indent",
}
}
pub fn from_ooxml(v: &str) -> Option<PTabBase> {
PTabBase::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum RubyAlign {
#[default]
Center,
/// Letters spread evenly from edge to edge.
DistributeLetter,
/// Letters spread evenly with half a gap at each edge.
DistributeSpace,
Left,
Right,
/// Right-aligned beside vertical text (laid out like `Right` in horizontal text).
RightVertical,
}
impl RubyAlign {
pub const ALL: [RubyAlign; 6] =
[RubyAlign::Center, RubyAlign::DistributeLetter, RubyAlign::DistributeSpace, RubyAlign::Left, RubyAlign::Right, RubyAlign::RightVertical];
/// The OOXML name (`center`, `distributeLetter`…), also used by commands.
pub fn ooxml(self) -> &'static str {
match self {
RubyAlign::Center => "center",
RubyAlign::DistributeLetter => "distributeLetter",
RubyAlign::DistributeSpace => "distributeSpace",
RubyAlign::Left => "left",
RubyAlign::Right => "right",
RubyAlign::RightVertical => "rightVertical",
}
}
pub fn from_ooxml(v: &str) -> Option<RubyAlign> {
RubyAlign::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// Phonetic Guide's Offset for a ruby `raise` (baseline to baseline, points): the gap from the top
/// of the base text to the bottom of the ruby text, taking letters as rising 0.8 em above their
/// baseline and dropping 0.2 em below it.
pub fn ruby_offset(raise: f32, base_size: f32, size: f32) -> f32 {
raise - 0.8 * base_size - 0.2 * size
}
/// The ruby raise (baseline to baseline, points) for a Phonetic Guide Offset (see [`ruby_offset`]).
pub fn ruby_raise(offset: f32, base_size: f32, size: f32) -> f32 {
offset + 0.8 * base_size + 0.2 * size
}
/// An inline object anchored at a U+FFFC in the paragraph text.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -459,11 +199,6 @@ pub enum InlineObject {
/// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)]
crop: [f32; 4],
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// read from a file: saving writes the object back, not only its picture. See
/// [`crate::graphic::Embedded`].
#[serde(default, skip_serializing_if = "Option::is_none")]
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
Graphic {
@@ -479,7 +214,7 @@ pub enum InlineObject {
kind: ShapeKind,
w: f32,
h: f32,
fill: Option<crate::fill::Fill>,
fill: Option<crate::props::Rgb>,
stroke: Option<crate::props::Rgb>,
#[serde(default)]
stroke_width: f32,
@@ -488,15 +223,9 @@ pub enum InlineObject {
/// Text box content: `Document::parts` id.
#[serde(default)]
story: Option<u32>,
/// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke.
#[serde(default, skip_serializing_if = "Option::is_none")]
freeform: Option<std::sync::Arc<crate::freeform::Freeform>>,
/// Shadow, glow and soft edges.
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
effects: crate::effects::ShapeEffects,
/// Drawing id, text warp, connector ends and arrowheads.
#[serde(default, skip_serializing_if = "crate::connector::ShapeExtra::is_empty")]
extra: crate::connector::ShapeExtra,
},
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
@@ -511,57 +240,27 @@ pub enum InlineObject {
ch_w: f32,
ch_h: f32,
children: Vec<GroupChild>,
/// A Drawing Canvas (Insert › Shapes › New Drawing Canvas, DrawingML's `wpc:wpc`) rather
/// than a group: it has its own background and outline, and its frame is independent of
/// its members: resizing it doesn't scale them (`ch_w` × `ch_h` follow `w` × `h`), and
/// what lies outside the frame is clipped.
#[serde(default, skip_serializing_if = "Option::is_none")]
canvas: Option<CanvasStyle>,
},
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
/// `result` the cached display text. A `locked` field keeps its result when fields update.
/// `result` the cached display text.
Field {
instr: String,
#[serde(default)]
result: String,
#[serde(default)]
locked: bool,
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
/// saves; cleared when WordCraft computes a result, like Word's F9.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
/// toggle: never written to files.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
code: bool,
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
#[serde(default, skip_serializing_if = "String::is_empty")]
form: String,
},
/// The start of a field whose result is ordinary content — formatted, possibly spanning
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Citation managers' `ADDIN`
/// fields (Zotero, Mendeley, EndNote) are kept this way; `instr` is the field code. See
/// [`crate::fields`].
FieldStart {
instr: String,
#[serde(default)]
locked: bool,
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
},
/// The end of the innermost open [`InlineObject::FieldStart`].
FieldEnd,
/// The start of a content control whose content is what follows, up to the matching
/// [`InlineObject::ControlEnd`]. See [`crate::control`].
ControlStart {
control: Box<crate::control::ContentControl>,
},
/// The end of the innermost open [`InlineObject::ControlStart`].
ControlEnd,
NoteRef {
kind: NoteKind,
/// `Document::parts` id of the note's story.
@@ -582,20 +281,6 @@ pub enum InlineObject {
CommentEnd {
id: u32,
},
/// The start of a range that stays editable while the document is protected (Restrict
/// Editing exceptions, `w:permStart`): for everyone (`group` = `everyone`), another editing
/// group, or one editor (`editor`, a user name or address).
PermStart {
id: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
group: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
editor: String,
},
/// The end of the editable range [`InlineObject::PermStart`] with the same `id` opened.
PermEnd {
id: u32,
},
/// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure.
/// A display equation sits on a line of its own.
Equation {
@@ -606,38 +291,6 @@ pub enum InlineObject {
#[serde(default, skip_serializing_if = "crate::math::Math::is_empty")]
math: crate::math::Math,
},
/// Ruby (Phonetic Guide): small `ruby` text (furigana, pinyin…) set over `base` text. The base
/// text takes the formatting of the object's run; it reads as the paragraph's text.
Ruby {
base: String,
ruby: String,
#[serde(default)]
align: RubyAlign,
/// Ruby text size, points (`w:hps`).
size: f32,
/// From the base text's baseline up to the ruby text's baseline, points (`w:hpsRaise`).
raise: f32,
/// The base text size the ruby was set for, points (`w:hpsBaseText`).
#[serde(default)]
base_size: f32,
/// Language of the ruby (`w:lid`, e.g. `ja-JP`); empty = unspecified.
#[serde(default, skip_serializing_if = "String::is_empty")]
lang: String,
/// Formatting of the ruby text over the run's (font, colour…); its size is `size`.
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
props: Box<CharProps>,
},
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
/// text after it lines up there. Text already past that place sends it to the next line.
PositionalTab {
#[serde(default)]
align: PTabAlign,
#[serde(default)]
relative_to: PTabBase,
#[serde(default)]
leader: crate::props::TabLeader,
},
/// Something we don't model, kept for round-trip (raw XML of the source format).
Opaque {
format: String,
@@ -660,16 +313,6 @@ pub struct GroupChild {
/// Most members a group keeps (hostile files).
pub const MAX_GROUP_CHILDREN: usize = 1000;
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CanvasStyle {
pub fill: Option<crate::fill::Fill>,
pub stroke: Option<crate::props::Rgb>,
/// Outline width, points.
pub stroke_width: f32,
}
impl InlineObject {
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
pub fn is_drawing(&self) -> bool {
@@ -695,7 +338,7 @@ impl InlineObject {
_ => None,
}
}
/// Resize a drawing (a group's members scale with it; a canvas's keep their size and place).
/// Resize a drawing (a group's members scale with it).
pub fn set_size(&mut self, nw: f32, nh: f32) {
if let InlineObject::Image { w, h, .. }
| InlineObject::Graphic { w, h, .. }
@@ -705,21 +348,6 @@ impl InlineObject {
*w = nw;
*h = nh;
}
if let InlineObject::Group { ch_w, ch_h, canvas: Some(_), .. } = self {
*ch_w = nw;
*ch_h = nh;
}
}
/// A Drawing Canvas's background and outline (`None` for anything else).
pub fn canvas_style(&self) -> Option<&CanvasStyle> {
match self {
InlineObject::Group { canvas, .. } => canvas.as_ref(),
_ => None,
}
}
/// Whether this is a Drawing Canvas.
pub fn is_canvas(&self) -> bool {
self.canvas_style().is_some()
}
/// The text box story this object shows, if it is a text box.
pub fn text_box(&self) -> Option<u32> {
@@ -767,14 +395,6 @@ impl InlineObject {
})
.collect()
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
match self {
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
_ => None,
}
}
/// Zero-width markers don't take part in layout.
pub fn is_marker(&self) -> bool {
matches!(
@@ -783,36 +403,10 @@ impl InlineObject {
| InlineObject::BookmarkEnd { .. }
| InlineObject::CommentStart { .. }
| InlineObject::CommentEnd { .. }
| InlineObject::PermStart { .. }
| InlineObject::PermEnd { .. }
| InlineObject::FieldStart { .. }
| InlineObject::FieldEnd
| InlineObject::ControlStart { .. }
| InlineObject::ControlEnd
)
}
/// A drawing's description (alternative text), wherever it is kept.
pub fn alt_text(&self) -> &str {
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text,
_ => "",
}
}
/// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing.
pub fn set_alt_text(&mut self, text: &str) -> bool {
let text = cap_alt(text);
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text,
_ => return false,
}
true
}
/// A drawing marked decorative.
pub fn is_decorative(&self) -> bool {
self.frame().is_some_and(|(_, _, f)| f.alt.decorative)
}
pub fn is_floating(&self) -> bool {
match self {
InlineObject::Image { float, .. }
@@ -822,38 +416,11 @@ impl InlineObject {
_ => false,
}
}
/// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one.
pub fn ink(&self) -> Option<&crate::freeform::Freeform> {
match self {
InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f),
_ => None,
}
}
/// The line a footnote or endnote separator draws (ECMA-376 §17.11: the `w:separator` and
/// `w:continuationSeparator` run elements): a short line, or with `continuation` one across
/// the column. Kept as the run element itself.
pub fn note_separator(continuation: bool) -> InlineObject {
let el = if continuation { "w:continuationSeparator" } else { "w:separator" };
InlineObject::Opaque { format: "docx".into(), xml: format!("<w:r><{el}/></w:r>"), text: String::new() }
}
/// For a [`InlineObject::note_separator`]: whether it is the continuation separator.
pub fn as_note_separator(&self) -> Option<bool> {
match self {
InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() {
"<w:r><w:separator/></w:r>" => Some(false),
"<w:r><w:continuationSeparator/></w:r>" => Some(true),
_ => None,
},
_ => None,
}
}
/// The text this object contributes to plain-text extraction.
pub fn plain_text(&self) -> &str {
match self {
InlineObject::Field { result, .. } => result,
InlineObject::Equation { linear, .. } => linear,
InlineObject::Ruby { base, .. } => base,
InlineObject::PositionalTab { .. } => "\t",
InlineObject::Opaque { text, .. } => text,
_ => "",
}
@@ -949,30 +516,6 @@ impl Paragraph {
}
out
}
/// [`Self::plain_text`] as it reads on screen: characters in runs set in a symbol font
/// (Symbol, Wingdings…) become their standard Unicode equivalents
/// ([`crate::symbol_fonts`]). For copying as text and plain-text export; the stored text is
/// unchanged.
pub fn shown_text(&self) -> String {
let mut out = String::with_capacity(self.text.len());
let mut k = 0;
let mut runs = self.run_ranges().peekable();
for (i, c) in self.text.char_indices() {
while runs.peek().is_some_and(|(r, _)| r.end <= i) {
runs.next();
}
if c == OBJ {
if let Some(o) = self.objects.get(k) {
out.push_str(o.plain_text());
}
k += 1;
continue;
}
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.shown_font());
out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c));
}
out
}
/// [`Self::plain_text`] without tracked deletions: the text as it reads with every change accepted.
pub fn final_text(&self) -> String {
self.text_without(&self.deleted_ranges())
@@ -992,7 +535,7 @@ impl Paragraph {
for (i, c) in self.text.char_indices() {
// By start: once the ranges that end by `i` are skipped, `i` is dropped if the next has begun.
while next.next_if(|r| r.end <= i).is_some() {}
let keep = next.peek().is_none_or(|r| r.start > i);
let keep = !next.peek().is_some_and(|r| r.start <= i);
if c == OBJ {
if keep && let Some(o) = self.objects.get(k) {
out.push_str(o.plain_text());
@@ -1089,12 +632,6 @@ impl Paragraph {
pub fn object_offsets(&self) -> Vec<usize> {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
}
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
}
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
pub fn insert_text(&mut self, off: usize, s: &str, props: &CharProps) -> Result<usize> {
@@ -1149,32 +686,32 @@ impl Paragraph {
/// Merge adjacent equal runs and drop empty ones.
pub fn normalize(&mut self) {
// Runs must end on character boundaries and cover the text exactly (never expected, but
// files lie): clamp each end to the text, push it past a split character, pad the tail.
let n = self.text.len();
let mut end = 0usize;
let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
for r in self.runs.drain(..) {
let mut e = end.saturating_add(r.len).min(n);
while !self.text.is_char_boundary(e) {
e += 1;
}
if e == end {
if r.len == 0 {
continue;
}
match out.last_mut() {
Some(l) if l.props == r.props => l.len += e - end,
_ => out.push(Run { len: e - end, props: r.props }),
}
end = e;
}
if end < n {
match out.last_mut() {
Some(l) => l.len += n - end,
None => out.push(Run { len: n, props: self.mark.clone() }),
Some(l) if l.props == r.props => l.len += r.len,
_ => out.push(r),
}
}
self.runs = out;
// Repair a run list that doesn't cover the text exactly (never expected, but files lie).
let total: usize = self.runs.iter().map(|r| r.len).sum();
if total != self.text.len() {
if total < self.text.len() {
let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone());
self.runs.push(Run { len: self.text.len() - total, props: p });
} else {
let mut keep = self.text.len();
for r in &mut self.runs {
r.len = r.len.min(keep);
keep -= r.len;
}
self.runs.retain(|r| r.len > 0);
}
}
}
/// Delete `a..b` (bytes). Objects inside are removed.
@@ -1188,12 +725,7 @@ impl Paragraph {
if self.runs.is_empty() || a == 0 {
// Keep the formatting of what's deleted for the mark when emptying.
if b == self.text.len() {
// Formatting only: the mark is a tracked change of its own or it is not, whatever
// revision the deleted text was part of.
let revisions = (self.mark.ins, self.mark.del, self.mark.fmt_change.clone());
let mut mark = self.props_of_char(a).clone();
(mark.ins, mark.del, mark.fmt_change) = revisions;
self.mark = mark;
self.mark = self.props_of_char(a).clone();
}
}
let k0 = self.objects_before(a);
@@ -1214,33 +746,6 @@ impl Paragraph {
Ok(())
}
/// Delete `a..b` except the one-character objects at the offsets in `keep` (control
/// markers a deletion leaves in place).
pub fn delete_except(&mut self, a: usize, b: usize, keep: &[usize]) -> Result<()> {
let (a, b) = (a.min(b), a.max(b));
let mut keep: Vec<usize> =
keep.iter().copied().filter(|k| *k >= a && k + OBJ.len_utf8() <= b && self.text.get(*k..).is_some_and(|t| t.starts_with(OBJ))).collect();
if keep.is_empty() {
return self.delete(a, b);
}
keep.sort_unstable();
keep.dedup();
// Pieces between the kept objects, last first so earlier offsets stay valid.
let mut pieces = Vec::with_capacity(keep.len() + 1);
let mut from = a;
for k in &keep {
pieces.push((from, *k));
from = k + OBJ.len_utf8();
}
pieces.push((from, b));
for (x, y) in pieces.into_iter().rev() {
if y > x {
self.delete(x, y)?;
}
}
Ok(())
}
/// Apply `f` to the formatting of `a..b`.
pub fn format(&mut self, a: usize, b: usize, f: &dyn Fn(&mut CharProps)) -> Result<()> {
let (a, b) = (a.min(b), a.max(b));
@@ -1296,8 +801,7 @@ impl Paragraph {
}
}
self.runs = keep;
// The new paragraph's mark takes the formatting at the split, but never a hyperlink.
let carry = self.props_at(off).unlinked();
let carry = self.props_at(off).clone();
let mut tail = Paragraph {
text: tail_text,
runs: tail_runs,
@@ -1308,7 +812,7 @@ impl Paragraph {
rev: next_rev(),
};
if tail.text.is_empty() {
tail.mark = self.props_at(off).unlinked();
tail.mark = self.props_at(off).clone();
}
self.normalize();
tail.normalize();
@@ -1329,24 +833,19 @@ impl Paragraph {
self.touch();
}
/// Previous caret stop before `off` (0 at start): a grapheme boundary that doesn't split a
/// complex-script syllable ([`crate::cluster`]).
/// Previous grapheme boundary before `off` (0 at start).
pub fn prev_boundary(&self, off: usize) -> usize {
crate::cluster::prev_cluster(&self.text, self.clamp(off))
}
/// Next caret stop after `off` (len at end), see [`Self::prev_boundary`].
pub fn next_boundary(&self, off: usize) -> usize {
crate::cluster::next_cluster(&self.text, self.clamp(off))
}
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
/// at a time, otherwise the previous caret stop.
pub fn backspace_from(&self, off: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
let off = self.clamp(off);
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
Some((i, c)) if crate::cluster::backspaces_by_char(c) => i,
_ => self.prev_boundary(off),
}
let mut c = GraphemeCursor::new(off, self.text.len(), true);
c.prev_boundary(&self.text, 0).ok().flatten().unwrap_or(0)
}
/// Next grapheme boundary after `off` (len at end).
pub fn next_boundary(&self, off: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
let off = self.clamp(off);
let mut c = GraphemeCursor::new(off, self.text.len(), true);
c.next_boundary(&self.text, 0).ok().flatten().unwrap_or(self.text.len())
}
/// Start of the word at or before `off` (Ctrl+Left).
pub fn word_start(&self, off: usize) -> usize {
@@ -1426,7 +925,7 @@ impl Paragraph {
let mut a = 0;
let mut prev_end = false;
for (i, c) in t.char_indices() {
if i > off {
if i >= off {
break;
}
if prev_end && c != ' ' {
@@ -1439,9 +938,7 @@ impl Paragraph {
}
}
let mut b = t.len();
// A caret on the space right after a sentence's end belongs to that sentence; a terminator
// directly followed by text ("Hello.World") doesn't end the sentence the caret is in.
let mut ended = t.get(..off).unwrap_or("").ends_with(['.', '!', '?']) && t.get(off..).unwrap_or("").starts_with(' ');
let mut ended = false;
for (i, c) in t.get(off..).unwrap_or("").char_indices() {
if ended && c != ' ' {
b = off + i;
@@ -1459,19 +956,6 @@ impl Paragraph {
mod tests {
use super::*;
#[test]
fn sentence_at_sentence_start() {
let p = Paragraph::with_text("Hello. World", CharProps::default());
assert_eq!(p.sentence_at(0), (0, 7));
assert_eq!(p.sentence_at(6), (0, 7));
assert_eq!(p.sentence_at(7), (7, 12));
assert_eq!(p.sentence_at(8), (7, 12));
assert_eq!(p.sentence_at(99), (7, 12));
let p = Paragraph::with_text("Hello.World", CharProps::default());
assert_eq!(p.sentence_at(6), (6, 11));
assert_eq!(p.sentence_at(8), (6, 11));
}
fn bold() -> CharProps {
CharProps { bold: Some(true), ..Default::default() }
}
@@ -1480,12 +964,7 @@ mod tests {
fn text_without_takes_ranges_in_any_order() {
// Multi-byte chars at range edges, an object, and ranges unsorted, overlapping, nested and empty.
let mut p = Paragraph::with_text("añb€c", CharProps::default());
p.insert_object(
3,
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
p.insert_object(3, InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false }, &CharProps::default()).unwrap();
let text = p.text.clone();
let naive = |dropped: &[std::ops::Range<usize>]| {
let mut out = String::new();
@@ -1554,22 +1033,12 @@ mod tests {
#[test]
fn objects_track_text() {
let mut p = Paragraph::with_text("ab", CharProps::default());
p.insert_object(
1,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default()).unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: "x".into() }, &CharProps::default()).unwrap();
assert_eq!(p.objects.len(), 2);
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
let off = p.object_offsets()[1];
assert!(matches!(p.object_at(off), Some(InlineObject::Field { .. })));
// The one-pass walk agrees with offsets + object_at.
let one_pass: Vec<(usize, &InlineObject)> = p.objects_with_offsets().collect();
let each: Vec<(usize, &InlineObject)> = p.object_offsets().into_iter().filter_map(|o| Some((o, p.object_at(o)?))).collect();
assert_eq!(one_pass, each);
assert_eq!(one_pass.len(), 2);
assert_eq!(p.plain_text(), "a1b");
p.delete(0, 3).unwrap();
assert_eq!(p.objects.len(), 1);
@@ -1593,24 +1062,6 @@ mod tests {
assert_eq!(t.mark.bold, Some(true));
}
#[test]
fn emptying_a_paragraph_keeps_the_formatting_but_not_the_revisions_of_its_text() {
let mut props = bold();
props.ins = Some(3);
props.del = Some(4);
let mut p = Paragraph::with_text("Hello", props);
p.mark = bold();
p.delete(0, 5).unwrap();
assert!(p.is_empty());
assert_eq!(p.mark.bold, Some(true));
assert_eq!((p.mark.ins, p.mark.del), (None, None));
// A mark that is itself a tracked change keeps its own revision.
let mut p = Paragraph::with_text("Hello", bold());
p.mark.del = Some(7);
p.delete(0, 5).unwrap();
assert_eq!((p.mark.ins, p.mark.del), (None, Some(7)));
}
#[test]
fn words() {
let p = Paragraph::with_text("The quick brown fox.", CharProps::default());
+27 -664
View File
@@ -6,38 +6,9 @@
use serde::{Deserialize, Serialize};
/// An sRGB colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl<'de> Deserialize<'de> for Rgb {
/// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in
/// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865).
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{Error, SeqAccess, Unexpected, Visitor};
struct V;
impl<'de> Visitor<'de> for V {
type Value = Rgb;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`")
}
fn visit_str<E: Error>(self, s: &str) -> Result<Rgb, E> {
Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut a: A) -> Result<Rgb, A::Error> {
let mut c = [0u8; 3];
for (i, x) in c.iter_mut().enumerate() {
*x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?;
}
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(4, &self));
}
Ok(Rgb(c[0], c[1], c[2]))
}
}
d.deserialize_any(V)
}
}
impl Rgb {
pub const BLACK: Rgb = Rgb(0, 0, 0);
pub const WHITE: Rgb = Rgb(255, 255, 255);
@@ -47,80 +18,26 @@ impl Rgb {
}
/// Parse `RRGGBB` or `#RRGGBB`.
pub fn parse(s: &str) -> Option<Rgb> {
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 || !s.is_ascii() {
return None;
}
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some(Rgb(p(0)?, p(2)?, p(4)?))
}
/// This colour with the DrawingML colour modifier `name` (`lumMod`, `lumOff`, `tint`,
/// `shade`, `satMod`, ECMA-376 §20.1.2.3) at `v` (1.0 = 100%); other names leave it
/// unchanged.
pub fn modified(self, name: &str, v: f64) -> Rgb {
match name {
"lumMod" => adjust_lum(self, |l| l * v),
"lumOff" => adjust_lum(self, |l| l + v),
"tint" => mix(self, 255.0, v),
"shade" => mix(self, 0.0, v),
"satMod" => {
let (h, sat, l) = to_hsl(self);
from_hsl(h, (sat * v).clamp(0.0, 1.0), l)
}
_ => self,
}
}
/// The colour with hue `h` (0..1 of a turn), saturation `s` and lightness `l` (0..1).
pub fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
from_hsl(h, s, l)
}
/// This colour with its HSL luminance changed by `f`.
pub fn with_lum(self, f: impl Fn(f64) -> f64) -> Rgb {
adjust_lum(self, f)
}
pub fn luma(self) -> f32 {
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
}
}
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum TextColor {
#[default]
Auto,
Rgb(Rgb),
}
impl TextColor {
/// `auto` or `automatic` (any case), or a colour [`Rgb::parse`] reads.
pub fn parse(s: &str) -> Option<TextColor> {
if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") { Some(TextColor::Auto) } else { Rgb::parse(s).map(TextColor::Rgb) }
}
}
impl<'de> Deserialize<'de> for TextColor {
/// Reads what it writes (`"Auto"`, `{"Rgb": [r, g, b]}`) and also a string [`TextColor::parse`]
/// reads, so `styles.modify` and `format.set` take the colours `format.color` takes.
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{Error, Unexpected};
#[derive(Deserialize)]
enum Written {
Auto,
Rgb(Rgb),
}
match serde_json::Value::deserialize(d)? {
serde_json::Value::String(s) => {
TextColor::parse(&s).ok_or_else(|| D::Error::invalid_value(Unexpected::Str(&s), &"`auto`, `RRGGBB` or `#RRGGBB`"))
}
v => Ok(match serde_json::from_value(v).map_err(D::Error::custom)? {
Written::Auto => TextColor::Auto,
Written::Rgb(c) => TextColor::Rgb(c),
}),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Underline {
@@ -180,46 +97,6 @@ impl Underline {
}
}
/// The OpenType ligatures a run asks for ([MS-DOCX] `w14:ligatures`): standard (`liga`),
/// contextual (`clig`), historical (`hlig`) and discretional (`dlig`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Ligatures {
pub standard: bool,
pub contextual: bool,
pub historical: bool,
pub discretional: bool,
}
impl Ligatures {
/// Every combination of the four flags.
pub fn all() -> impl Iterator<Item = Ligatures> {
(0u8..16).map(|b| Ligatures { standard: b & 1 != 0, contextual: b & 2 != 0, historical: b & 4 != 0, discretional: b & 8 != 0 })
}
/// The `w14:ligatures/@w14:val` name: `none`, `all`, or the set flags joined in camelCase
/// (`standardContextual`).
pub fn ooxml(self) -> String {
let on: Vec<&str> =
[(self.standard, "standard"), (self.contextual, "contextual"), (self.historical, "historical"), (self.discretional, "discretional")]
.into_iter()
.filter_map(|(on, n)| on.then_some(n))
.collect();
match on.len() {
0 => "none".into(),
4 => "all".into(),
_ => on
.iter()
.enumerate()
.flat_map(|(i, n)| n.chars().enumerate().map(move |(j, c)| if i > 0 && j == 0 { c.to_ascii_uppercase() } else { c }))
.collect(),
}
}
/// `None` for a name [MS-DOCX] doesn't define.
pub fn from_ooxml(s: &str) -> Option<Ligatures> {
Ligatures::all().find(|l| l.ooxml() == s)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VertAlign {
@@ -342,9 +219,6 @@ impl Highlight {
}
}
/// The text size, in points, of a run whose size no style or default sets.
pub const DEFAULT_FONT_SIZE: f32 = 11.0;
/// Character formatting (direct or in a style). `None` = inherit.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -354,11 +228,6 @@ pub struct CharProps {
pub style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub font: Option<String>,
/// The symbol's own font (OOXML `w:sym/@w:font`, ECMA-376 §17.3.3.30), set on a symbol
/// character from a `w:sym` or Insert › Symbol: it is shown in this font whatever `font` is,
/// so changing the text's font keeps the symbol. `None` = the run's font.
#[serde(skip_serializing_if = "Option::is_none")]
pub sym_font: Option<String>,
/// Points.
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<f32>,
@@ -404,10 +273,6 @@ pub struct CharProps {
/// Kerning for fonts at or above this size (points); 0 = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub kern: Option<f32>,
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
/// contextual ligatures.
#[serde(skip_serializing_if = "Option::is_none")]
pub ligatures: Option<Ligatures>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outline: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -416,10 +281,6 @@ pub struct CharProps {
pub emboss: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub engrave: Option<bool>,
/// WordArt text effects: fill, outline, shadow, glow, reflection ([MS-DOCX] `w14:` run
/// properties). Overlaid effect by effect.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_effects: Option<Box<crate::wordart::TextEffects>>,
/// BCP 47 language tag (proofing).
#[serde(skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
@@ -434,14 +295,10 @@ pub struct CharProps {
/// Complex-script font (Arabic, Persian, Hebrew…; OOXML `w:rFonts/@w:cs`). `None` = `font`.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_cs: Option<String>,
/// East Asian font (Chinese, Japanese and Korean text; OOXML `w:rFonts/@w:eastAsia`). `None` =
/// inherit, then `font`.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_ea: Option<String>,
/// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged.
/// Complex-script size, points (`w:szCs`). `None` = the same as `size` (see [`CharProps::overlay`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub size_cs: Option<f32>,
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged.
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` = the same as `bold` / `italic`.
#[serde(skip_serializing_if = "Option::is_none")]
pub bold_cs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -449,13 +306,6 @@ pub struct CharProps {
/// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`).
#[serde(skip_serializing_if = "Option::is_none")]
pub lang_bidi: Option<String>,
/// BCP 47 language of East Asian text (`w:lang/@w:eastAsia`, e.g. `zh-CN`, `ja-JP`, `ko-KR`).
#[serde(skip_serializing_if = "Option::is_none")]
pub lang_ea: Option<String>,
/// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`).
/// Kept for round trip; layout doesn't apply it yet.
#[serde(skip_serializing_if = "Option::is_none")]
pub east_asian_layout: Option<EastAsianLayout>,
/// Hyperlink target: a URL, or `#bookmark` for an internal link.
#[serde(skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
@@ -465,91 +315,6 @@ pub struct CharProps {
/// Tracked deletion: index into `Document::revisions`.
#[serde(skip_serializing_if = "Option::is_none")]
pub del: Option<u32>,
/// Tracked formatting change (`w:rPrChange`): the formatting before it.
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CharProps>>>,
}
/// East Asian typography settings of a run (ISO/IEC 29500-1 §17.3.2.10, `w:eastAsianLayout`):
/// *two lines in one* sets the run's text on two sub-lines inside one line (warichu), *horizontal
/// in vertical* turns it 90° to read across a vertical line (tate-chu-yoko). Each attribute is
/// `None` when the file doesn't say.
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct EastAsianLayout {
/// East Asian typography run id (`w:id`).
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<i32>,
/// Two lines in one (`w:combine`).
#[serde(skip_serializing_if = "Option::is_none")]
pub combine: Option<bool>,
/// Brackets around two lines in one (`w:combineBrackets`).
#[serde(skip_serializing_if = "Option::is_none")]
pub combine_brackets: Option<CombineBrackets>,
/// Horizontal in vertical (`w:vert`).
#[serde(skip_serializing_if = "Option::is_none")]
pub vert: Option<bool>,
/// Compress horizontal-in-vertical text to the line height (`w:vertCompress`).
#[serde(skip_serializing_if = "Option::is_none")]
pub vert_compress: Option<bool>,
}
/// Brackets around two lines in one (`ST_CombineBrackets`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CombineBrackets {
None,
Round,
Square,
Angle,
Curly,
}
impl CombineBrackets {
pub const ALL: [CombineBrackets; 5] =
[CombineBrackets::None, CombineBrackets::Round, CombineBrackets::Square, CombineBrackets::Angle, CombineBrackets::Curly];
/// OOXML `w:combineBrackets` value.
pub fn ooxml(self) -> &'static str {
match self {
CombineBrackets::None => "none",
CombineBrackets::Round => "round",
CombineBrackets::Square => "square",
CombineBrackets::Angle => "angle",
CombineBrackets::Curly => "curly",
}
}
/// The value for `s`, `None` for one the schema doesn't have.
pub fn from_ooxml(s: &str) -> Option<CombineBrackets> {
CombineBrackets::ALL.into_iter().find(|b| b.ooxml() == s)
}
}
/// A tracked formatting change (ECMA-376 §17.13.5: `w:rPrChange`, `w:pPrChange`,
/// `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:sectPrChange`): the properties as they
/// were before the change, and the revision (author, date) that made it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PropChange<T> {
/// Index into `Document::revisions`.
pub rev: u32,
/// The properties before the change (formatting only: no revision or link marks).
pub old: T,
}
impl<T> PropChange<T> {
pub fn boxed(rev: u32, old: T) -> Option<Box<PropChange<T>>> {
Some(Box::new(PropChange { rev, old }))
}
}
/// A tracked change of a paragraph's list numbering (`w:numberingChange`, kept for round trip):
/// `original` is the number as it was shown before the change.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct NumChange {
/// Index into `Document::revisions`.
pub rev: u32,
pub original: String,
}
macro_rules! overlay_fields {
@@ -558,42 +323,26 @@ macro_rules! overlay_fields {
};
}
macro_rules! drop_same_fields {
($dst:ident, $base:ident; $($f:ident),*) => {
$( if $dst.$f == $base.$f { $dst.$f = None; } )*
};
}
impl CharProps {
/// The same formatting outside a hyperlink: no link target, and no "Hyperlink" character
/// style that came with it. What a paragraph mark or text typed next to a link gets.
pub fn unlinked(&self) -> CharProps {
let mut c = self.clone();
if c.link.take().is_some() && c.style.as_deref() == Some("Hyperlink") {
c.style = None;
}
c
}
/// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when
/// overlaid on `base`.
pub fn minus(mut self, base: &CharProps) -> CharProps {
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures);
self
}
/// Apply every `Some` field of `patch` on top of `self`.
///
/// Plain and complex-script properties inherit independently. Commands that intend to
/// format both scripts must set both values explicitly.
/// Size, bold and italic come in pairs with their complex-script values (Word's own buttons
/// set both): a patch that sets the plain value and leaves the complex-script one `None` sets
/// both, so a run's size or bold isn't overridden for its Persian/Arabic characters by an
/// inherited `w:szCs` / `w:bCs`. The complex-script font inherits on its own (Word often
/// sets only the Latin font and lets complex-script text keep the style's).
pub fn overlay(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures);
if let Some(p) = &patch.text_effects {
self.text_effects.get_or_insert_with(Default::default).overlay(p);
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del);
if patch.size.is_some() && patch.size_cs.is_none() {
self.size_cs = None;
}
if patch.bold.is_some() && patch.bold_cs.is_none() {
self.bold_cs = None;
}
if patch.italic.is_some() && patch.italic_cs.is_none() {
self.italic_cs = None;
}
}
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
@@ -603,33 +352,10 @@ impl CharProps {
pub fn is_empty(&self) -> bool {
*self == CharProps::default()
}
/// The font a character of this run is shown in: the symbol's own font when it has one
/// ([`Self::sym_font`]), else the run's font.
pub fn shown_font(&self) -> Option<&str> {
self.sym_font.as_deref().filter(|f| !f.trim().is_empty()).or(self.font.as_deref())
}
/// The same formatting for text typed or painted next to a symbol: without the symbol's own
/// font.
pub fn without_symbol(mut self) -> CharProps {
self.sym_font = None;
self
}
/// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style
/// is cleared too, as Word's Clear Formatting does).
pub fn cleared(&self) -> CharProps {
CharProps {
link: self.link.clone(),
ins: self.ins,
del: self.del,
fmt_change: self.fmt_change.clone(),
// A symbol stays the symbol it is.
sym_font: self.sym_font.clone(),
..Default::default()
}
}
/// Just the formatting: without the link, revision marks and tracked formatting change.
pub fn formatting(&self) -> CharProps {
CharProps { link: None, ins: None, del: None, fmt_change: None, ..self.clone() }
CharProps { link: self.link.clone(), ins: self.ins, del: self.del, ..Default::default() }
}
}
@@ -711,10 +437,6 @@ impl TabLeader {
}
}
/// Two tab positions closer than this (points) are the same stop: `para.tabs` replaces at this
/// tolerance, and the Tabs dialog and the ruler merge and detect no-ops with it.
pub const TAB_POS_EPS: f32 = 0.5;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct TabStop {
/// Points from the paragraph's left indent origin (the text column's left edge).
@@ -845,33 +567,6 @@ pub struct NumRef {
pub level: u8,
}
/// A text frame's placement (`w:framePr`, ECMA-376 §17.3.1.11), in points. Offsets apply when
/// the matching alignment is unset; anchors and the wrap keep OOXML's names.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TextFrame {
/// Width; `None` fits the text.
pub w: Option<f32>,
/// Height, with `h_exact` exact rather than at least; `None` fits the text.
pub h: Option<f32>,
pub h_exact: bool,
pub x: f32,
pub y: f32,
/// `left`, `center`, `right`, `inside`, `outside`.
pub x_align: Option<String>,
/// `top`, `center`, `bottom`, `inside`, `outside` (`inline` is no alignment).
pub y_align: Option<String>,
/// `text` (the column), `margin` or `page`.
pub h_anchor: String,
/// `text` (the paragraph), `margin` or `page`.
pub v_anchor: String,
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
pub wrap: String,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
}
/// Paragraph formatting. `None` = inherit.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -889,38 +584,15 @@ pub struct ParaProps {
/// First-line indent, points; negative = hanging.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first: Option<f32>,
/// Left (start) indent in characters (`w:leftChars` / `w:startChars`, stored by Word in
/// hundredths). A non-zero value wins over `indent_left`; one character is the size of the
/// paragraph mark's font. `Some(0.0)` cancels an inherited value. Common in East Asian documents.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_left_chars: Option<f32>,
/// Right (end) indent in characters (`w:rightChars` / `w:endChars`), like `indent_left_chars`.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_right_chars: Option<f32>,
/// First-line indent in characters (`w:firstLineChars`; negative = `w:hangingChars`), like
/// `indent_left_chars`.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first_chars: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after: Option<f32>,
/// HTML-style automatic space before (`w:beforeAutospacing`): 14 pt, none at the top of the
/// document or of a table cell, and collapsing with the space after the paragraph above.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before_auto: Option<bool>,
/// HTML-style automatic space after (`w:afterAutospacing`): 14 pt, none at the end of a cell.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after_auto: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_spacing: Option<LineSpacing>,
/// Ignore space before/after between paragraphs of the same style.
#[serde(skip_serializing_if = "Option::is_none")]
pub contextual_spacing: Option<bool>,
/// `w:mirrorIndents` (ECMA-376 §17.3.1.18): the left and right indents are the inside and
/// outside ones, so they swap sides on even (left-hand) pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror_indents: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_next: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -950,20 +622,6 @@ pub struct ParaProps {
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
/// Drop cap "In margin" (`w:dropCap="margin"`): the letter hangs in the left margin and the
/// text keeps its indent. Unset = dropped into the text.
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap_margin: Option<bool>,
/// The drop cap's distance from the text, in points (`w:hSpace`). Unset = 0.
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap_distance: Option<f32>,
/// The drop cap letter's font family (the cap run's `w:rFonts`). Unset = the text's font.
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap_font: Option<String>,
/// A text frame (`w:framePr` other than a drop cap): the paragraph is placed apart from the
/// text, which may wrap around it — a footer's page number in a frame, for instance.
#[serde(skip_serializing_if = "Option::is_none")]
pub frame: Option<TextFrame>,
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
/// Unset = on.
@@ -985,23 +643,13 @@ pub struct ParaProps {
/// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on.
#[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")]
pub auto_space_dn: Option<bool>,
/// Tracked change of the list numbering (`w:numberingChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub num_change: Option<Box<NumChange>>,
/// Tracked formatting change (`w:pPrChange`): the paragraph properties before it.
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<ParaProps>>>,
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
}
impl ParaProps {
pub fn overlay(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing,
contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
snap_to_grid);
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing,
contextual_spacing, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
@@ -1010,19 +658,6 @@ impl ParaProps {
pub fn is_empty(&self) -> bool {
*self == ParaProps::default()
}
/// After setting indents in points: keep the indents in characters (left, right, first line)
/// an inherited style may set from winning over them, as Word does (`w:firstLineChars="0"`).
pub fn cancel_char_indents(&mut self, left: bool, right: bool, first: bool) {
for (on, chars) in [(left, &mut self.indent_left_chars), (right, &mut self.indent_right_chars), (first, &mut self.indent_first_chars)] {
if on {
*chars = Some(0.0);
}
}
}
/// Just the formatting: without the tracked changes.
pub fn formatting(&self) -> ParaProps {
ParaProps { num_change: None, fmt_change: None, ..self.clone() }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
@@ -1078,71 +713,6 @@ impl TextDirection {
}
}
/// Which way a text box's text runs (Shape Format › Text › Text Direction; DrawingML
/// `a:bodyPr/@vert`, ECMA-376 §21.1.10.83 `ST_TextVerticalType`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum TextVert {
/// Upright, lines stacking downwards (`horz`).
#[default]
Horz,
/// Turned 90° clockwise (`vert`).
Vert,
/// Turned 270° (`vert270`).
Vert270,
/// Upright letters stacked one above the other (`wordArtVert`).
Stacked,
}
impl TextVert {
/// The direction an `ST_TextVerticalType` value names; the East Asian and Mongolian vertical
/// layouts turn like `vert`, unknown values are horizontal.
pub fn from_ooxml(v: &str) -> TextVert {
match v {
"vert" | "eaVert" | "mongolianVert" => TextVert::Vert,
"vert270" => TextVert::Vert270,
"wordArtVert" | "wordArtVertRtl" => TextVert::Stacked,
_ => TextVert::Horz,
}
}
pub fn ooxml(self) -> &'static str {
match self {
TextVert::Horz => "horz",
TextVert::Vert => "vert",
TextVert::Vert270 => "vert270",
TextVert::Stacked => "wordArtVert",
}
}
/// How its lines turn on the page (stacked text stays upright).
pub fn turn(self) -> TextDirection {
match self {
TextVert::Vert => TextDirection::Down,
TextVert::Vert270 => TextDirection::Up,
TextVert::Horz | TextVert::Stacked => TextDirection::Horizontal,
}
}
}
/// Where a text box's text sits between its top and bottom (Align Text; `a:bodyPr/@anchor`,
/// `ST_TextAnchoringType`): reuses [`VAlign`]. Justified and distributed anchoring read as top
/// and middle.
pub fn valign_from_anchor(v: &str) -> VAlign {
match v {
"ctr" | "dist" => VAlign::Center,
"b" => VAlign::Bottom,
_ => VAlign::Top,
}
}
/// The `ST_TextAnchoringType` value for `a`.
pub fn anchor_ooxml(a: VAlign) -> &'static str {
match a {
VAlign::Top => "t",
VAlign::Center => "ctr",
VAlign::Bottom => "b",
}
}
/// Table-wide properties.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -1164,22 +734,11 @@ pub struct TableProps {
/// column, banded columns.
pub look: TableLook,
pub shading: Option<Rgb>,
/// Alternative text: the title (`w:tblCaption`).
/// Alternative text.
pub caption: Option<String>,
/// Alternative text: the description (`w:tblDescription`).
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Floating placement (`w:tblpPr`); `None` for a table in the text flow.
#[serde(skip_serializing_if = "Option::is_none")]
pub float: Option<TableFloat>,
/// Tracked formatting change (`w:tblPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<TableProps>>>,
/// Right-to-left table (`w:bidiVisual`): the first logical column stands at the right edge,
/// cell margins and left/right borders mirror, and the alignment and indent are measured from
/// the right margin.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub bidi_visual: bool,
}
/// Where a floating table sits; the text after it wraps around it.
@@ -1245,29 +804,6 @@ pub struct RowProps {
pub header: bool,
/// Don't let the row break across pages.
pub cant_split: bool,
/// Table properties this row overrides (`w:tblPrEx`, ECMA-376 §17.4.61): width, alignment,
/// indent, borders, shading, layout and cell margins. `Some` when the file has the element.
#[serde(skip_serializing_if = "Option::is_none")]
pub exceptions: Option<Box<TableProps>>,
/// Grid columns skipped before the row's first cell (`w:gridBefore`), and the preferred
/// width of that gap in points (`w:wBefore`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_before: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_before: Option<f32>,
/// Grid columns left after the row's last cell (`w:gridAfter`), and their width in points
/// (`w:wAfter`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_after: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_after: Option<f32>,
/// Tracked formatting change (`w:trPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<RowProps>>>,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
@@ -1294,93 +830,12 @@ pub struct CellProps {
pub vmerge: VMerge,
pub shading: Option<Rgb>,
pub borders: Option<Borders>,
/// Vertical alignment (`w:vAlign`); `None` follows the table style, else top. An explicit
/// `Top` beats the style.
#[serde(skip_serializing_if = "Option::is_none")]
pub valign: Option<VAlign>,
pub valign: VAlign,
/// Cell margins override (top, left, bottom, right), points.
pub margins: Option<[f32; 4]>,
/// Per-side overrides when `margins` is absent; omitted sides inherit table defaults.
#[serde(skip_serializing_if = "Option::is_none")]
pub partial_margins: Option<[Option<f32>; 4]>,
/// Which way the cell's text runs.
pub text_direction: TextDirection,
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub text_direction_set: bool,
pub no_wrap: bool,
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
/// empty cells shrinks to its borders.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub hide_mark: bool,
/// Tracked formatting change (`w:tcPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CellProps>>>,
}
impl CellProps {
/// Resolve top, left, bottom and right margins against the current table defaults.
/// A complete override takes precedence over per-side overrides.
pub fn resolved_margins(&self, defaults: [f32; 4]) -> [f32; 4] {
if let Some(margins) = self.margins {
return margins;
}
self.partial_margins.map_or(defaults, |sides| std::array::from_fn(|i| sides[i].unwrap_or(defaults[i])))
}
}
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
Rgb(f(c.0), f(c.1), f(c.2))
}
/// `c` with its HSL luminance changed by `f`.
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
let (h, s, l) = to_hsl(c);
from_hsl(h, s, f(l).clamp(0.0, 1.0))
}
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
let l = (max + min) / 2.0;
if max == min {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
(h / 6.0, s, l)
}
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
if s == 0.0 {
return Rgb(byte(l), byte(l), byte(l));
}
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let p = 2.0 * l - q;
let channel = |t: f64| {
let t = t.rem_euclid(1.0);
let v = if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
};
byte(v)
};
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
}
#[cfg(test)]
@@ -1395,16 +850,6 @@ mod tests {
assert_eq!(Rgb::parse("é1234"), None);
}
#[test]
fn rgb_parse_rejects_non_hex() {
assert_eq!(Rgb::parse("+1+2+3"), None);
assert_eq!(Rgb::parse("-1-2-3"), None);
assert_eq!(Rgb::parse("##ff0000"), None);
assert_eq!(Rgb::parse("ff00 0"), None);
assert_eq!(Rgb::parse("#ff0000"), Some(Rgb(255, 0, 0)));
assert_eq!(Rgb::parse("ff0000"), Some(Rgb(255, 0, 0)));
}
#[test]
fn overlay_keeps_unset() {
let base = CharProps { bold: Some(true), size: Some(12.0), ..Default::default() };
@@ -1441,37 +886,6 @@ mod tests {
}
}
/// The 16 `w14:ligatures` values of [MS-DOCX], each to its flags and back.
#[test]
fn ligature_names() {
let l = |standard, contextual, historical, discretional| Ligatures { standard, contextual, historical, discretional };
for (name, flags) in [
("none", l(false, false, false, false)),
("standard", l(true, false, false, false)),
("contextual", l(false, true, false, false)),
("historical", l(false, false, true, false)),
("discretional", l(false, false, false, true)),
("standardContextual", l(true, true, false, false)),
("standardHistorical", l(true, false, true, false)),
("contextualHistorical", l(false, true, true, false)),
("standardDiscretional", l(true, false, false, true)),
("contextualDiscretional", l(false, true, false, true)),
("historicalDiscretional", l(false, false, true, true)),
("standardContextualHistorical", l(true, true, true, false)),
("standardContextualDiscretional", l(true, true, false, true)),
("standardHistoricalDiscretional", l(true, false, true, true)),
("contextualHistoricalDiscretional", l(false, true, true, true)),
("all", l(true, true, true, true)),
] {
assert_eq!(Ligatures::from_ooxml(name), Some(flags), "{name}");
assert_eq!(flags.ooxml(), name);
}
assert_eq!(Ligatures::all().count(), 16);
for bad in ["", "Standard", "contextualStandard", "standardstandard", "allx"] {
assert_eq!(Ligatures::from_ooxml(bad), None, "{bad:?}");
}
}
#[test]
fn text_direction_names() {
let mut d = TextDirection::Horizontal;
@@ -1489,42 +903,6 @@ mod tests {
assert_eq!(TextDirection::from_ooxml("é?"), TextDirection::Horizontal);
}
/// #865: a colour reads as `format.color` takes it (`RRGGBB`, `#RRGGBB`, `auto`), while what
/// documents and settings already hold reads and writes exactly as before.
#[test]
fn text_color_reads_hex_and_keeps_its_serialized_forms() {
let read = |j: &str| serde_json::from_str::<TextColor>(j);
let ink = TextColor::Rgb(Rgb(0x1B, 0x16, 0x11));
assert_eq!(read(r#""1B1611""#).unwrap(), ink);
assert_eq!(read(r##""#1b1611""##).unwrap(), ink);
assert_eq!(read(r#""auto""#).unwrap(), TextColor::Auto);
assert_eq!(read(r#""Automatic""#).unwrap(), TextColor::Auto);
for (c, j) in [(TextColor::Auto, r#""Auto""#), (ink, r#"{"Rgb":[27,22,17]}"#)] {
assert_eq!(serde_json::to_string(&c).unwrap(), j, "written as before");
assert_eq!(read(j).unwrap(), c);
}
for bad in [r#""1B161""#, r#""zzzzzz""#, r#""""#, r#"{"Rgb":"1B16"}"#, r#"{"Rgb":[1,2]}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] {
assert!(read(bad).is_err(), "{bad}");
}
assert_eq!(read(r#"{"Rgb":"1B1611"}"#).unwrap(), ink);
let p: CharProps = serde_json::from_value(serde_json::json!({"color": "#1B1611", "bold": true})).unwrap();
assert_eq!((p.color, p.bold), (Some(ink), Some(true)));
}
/// #865: an [`Rgb`] reads `RRGGBB` / `#RRGGBB` as well as the `[r, g, b]` it still writes.
#[test]
fn rgb_reads_hex_and_keeps_its_serialized_form() {
let read = |j: &str| serde_json::from_str::<Rgb>(j);
let ink = Rgb(0x1B, 0x16, 0x11);
assert_eq!(serde_json::to_string(&ink).unwrap(), "[27,22,17]", "written as before");
for j in ["[27,22,17]", r#""1B1611""#, r##""#1b1611""##] {
assert_eq!(read(j).unwrap(), ink, "{j}");
}
for bad in ["[27,22]", "[27,22,17,0]", "[27,22,300]", "[-1,0,0]", r#""auto""#, r#""1B161""#, r#""""#, "7", "null", r#"{"r":1}"#] {
assert!(read(bad).is_err(), "{bad}");
}
}
#[test]
fn props_serde_skip_none() {
let p = CharProps { bold: Some(true), ..Default::default() };
@@ -1533,19 +911,4 @@ mod tests {
let back: CharProps = serde_json::from_str(&s).unwrap();
assert_eq!(back, p);
}
#[test]
fn symbol_font_is_optional_in_json_and_survives_font_changes() {
// Older documents (no `symFont`) still load.
let old: CharProps = serde_json::from_str(r#"{"font":"Arial"}"#).unwrap();
assert_eq!(old.sym_font, None);
let p = CharProps { font: Some("Arial".into()), sym_font: Some("Wingdings".into()), ..Default::default() };
let s = serde_json::to_string(&p).unwrap();
assert!(s.contains(r#""symFont":"Wingdings""#), "{s}");
assert_eq!(serde_json::from_str::<CharProps>(&s).unwrap(), p);
// A font change keeps the symbol's own font; Clear Formatting too.
let changed = p.clone().overlaid(&CharProps { font: Some("Times New Roman".into()), ..Default::default() });
assert_eq!(changed.sym_font.as_deref(), Some("Wingdings"));
assert_eq!(p.cleared().sym_font.as_deref(), Some("Wingdings"));
}
}
+6 -112
View File
@@ -8,12 +8,6 @@ use crate::props::{
};
use crate::styles::{StyleKind, StyleSheet};
/// Automatic (HTML-style) paragraph spacing, points: what Word for Windows gives `w:beforeAutospacing`
/// and `w:afterAutospacing` at any font size (measured).
pub const AUTO_SPACING: f32 = 14.0;
/// The largest drop cap distance from the text, in points (22", as Word's dialog allows).
pub const MAX_DROP_CAP_DISTANCE: f32 = 1584.0;
/// Concrete character formatting.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -38,25 +32,13 @@ pub struct ResolvedChar {
pub spacing: f32,
pub scale: f32,
pub position: f32,
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
#[serde(default)]
pub kern: f32,
/// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and
/// contextual).
#[serde(default)]
pub ligatures: Option<crate::props::Ligatures>,
pub outline: bool,
pub shadow: bool,
pub emboss: bool,
pub engrave: bool,
/// WordArt text effects (sanitized; `None` when there are none).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_effects: Option<Box<crate::wordart::TextEffects>>,
pub link: Option<String>,
pub ins: Option<u32>,
pub del: Option<u32>,
/// The revision of a tracked formatting change (`w:rPrChange`) of the run.
pub fmt: Option<u32>,
pub no_proof: bool,
pub lang: Option<String>,
/// Right-to-left run (`w:rtl`).
@@ -65,9 +47,6 @@ pub struct ResolvedChar {
pub cs: bool,
/// Complex-script font, size, bold and italic (Arabic, Persian, Hebrew… text).
pub font_cs: String,
/// East Asian font (Chinese, Japanese and Korean text), when one is set.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_ea: Option<String>,
pub size_cs: f32,
pub bold_cs: bool,
pub italic_cs: bool,
@@ -76,9 +55,8 @@ pub struct ResolvedChar {
impl ResolvedChar {
fn from(c: &CharProps) -> ResolvedChar {
ResolvedChar {
// A symbol character is shown in its own font (`w:sym`), whatever the run's font.
font: c.shown_font().map(str::to_string).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
size: c.size.unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
font: c.font.clone().unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0),
bold: c.bold.unwrap_or(false),
italic: c.italic.unwrap_or(false),
underline: c.underline.unwrap_or_default(),
@@ -96,24 +74,19 @@ impl ResolvedChar {
spacing: c.spacing.unwrap_or(0.0).clamp(-100.0, 100.0),
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
ligatures: c.ligatures,
outline: c.outline.unwrap_or(false),
shadow: c.shadow.unwrap_or(false),
emboss: c.emboss.unwrap_or(false),
engrave: c.engrave.unwrap_or(false),
text_effects: c.text_effects.as_deref().map(crate::wordart::TextEffects::sanitized).filter(|t| !t.is_empty()).map(Box::new),
link: c.link.clone(),
ins: c.ins,
del: c.del,
fmt: c.fmt_change.as_ref().map(|f| f.rev),
no_proof: c.no_proof.unwrap_or(false),
lang: c.lang.clone(),
rtl: c.rtl.unwrap_or(false),
cs: c.cs.unwrap_or(false),
font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
font_ea: c.font_ea.clone(),
size_cs: c.size_cs.or(c.size).unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0),
bold_cs: c.bold_cs.or(c.bold).unwrap_or(false),
italic_cs: c.italic_cs.or(c.italic).unwrap_or(false),
}
@@ -124,11 +97,6 @@ impl ResolvedChar {
pub fn complex(&self) -> ResolvedChar {
ResolvedChar { font: self.font_cs.clone(), size: self.size_cs, bold: self.bold_cs, italic: self.italic_cs, ..self.clone() }
}
/// Whether the text is kerned: Word kerns only a run with Kerning for fonts set (`w:kern`) at
/// a size of at least that many points, never by default (measured in Word for Windows).
pub fn kerned(&self) -> bool {
self.kern > 0.0 && self.size >= self.kern
}
/// Does a character of this run use the complex-script properties?
pub fn uses_complex(&self, c: char) -> bool {
self.rtl || self.cs || crate::bidi::is_complex_script(c)
@@ -159,16 +127,8 @@ pub struct ResolvedPara {
pub indent_first: f32,
pub space_before: f32,
pub space_after: f32,
/// The space before or after is automatic (HTML-style, [`AUTO_SPACING`]): see
/// `ParaProps::space_before_auto`.
#[serde(default)]
pub before_auto: bool,
#[serde(default)]
pub after_auto: bool,
pub line_spacing: LineSpacing,
pub contextual_spacing: bool,
/// Left/right indents are inside/outside: they swap on even pages.
pub mirror_indents: bool,
pub keep_next: bool,
pub keep_lines: bool,
pub page_break_before: bool,
@@ -180,20 +140,7 @@ pub struct ResolvedPara {
pub borders: Option<Borders>,
pub bidi: bool,
pub drop_cap: u8,
/// The drop cap hangs in the margin (see `ParaProps::drop_cap_margin`).
#[serde(default)]
pub drop_cap_margin: bool,
/// The drop cap's distance from the text, in points.
#[serde(default)]
pub drop_cap_distance: f32,
/// The drop cap letter's font family; `None` = the text's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drop_cap_font: Option<String>,
/// A text frame the paragraph sits in (see `ParaProps::frame`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame: Option<crate::props::TextFrame>,
pub suppress_hyphens: bool,
pub suppress_line_numbers: bool,
/// Asian typography (see `ParaProps`): East Asian line-breaking rules.
pub kinsoku: bool,
/// Latin words wrap whole (`false`: they may break at any character).
@@ -202,12 +149,6 @@ pub struct ResolvedPara {
pub top_line_punct: bool,
pub auto_space_de: bool,
pub auto_space_dn: bool,
/// Lines snap to the document grid, when the section has one.
pub snap_to_grid: bool,
/// Indents in characters (left, right, first line; 0 = in points), which win over the
/// point values; see [`ResolvedPara::set_char_unit`].
#[serde(default)]
pub indent_chars: [f32; 3],
}
const MAX_INDENT: f32 = 1584.0; // 22"
@@ -219,18 +160,6 @@ impl ResolvedPara {
pub fn visual_indents(&self) -> (f32, f32) {
if self.bidi { (self.indent_right, self.indent_left) } else { (self.indent_left, self.indent_right) }
}
/// Turn the indents given in characters into points, one character being `size` points: the
/// size of the paragraph mark's font (as Word measures `w:firstLineChars` and friends).
pub fn set_char_unit(&mut self, size: f32) {
let size = if size.is_finite() { size.clamp(1.0, 1638.0) } else { 12.0 };
let [l, r, f] = self.indent_chars;
for (chars, pts) in [(l, &mut self.indent_left), (r, &mut self.indent_right), (f, &mut self.indent_first)] {
if chars != 0.0 {
*pts = (chars * size).clamp(-MAX_INDENT, MAX_INDENT);
}
}
}
}
impl ResolvedPara {
@@ -245,17 +174,14 @@ impl ResolvedPara {
indent_left: clampi(p.indent_left),
indent_right: clampi(p.indent_right),
indent_first: clampi(p.indent_first),
space_before: if p.space_before_auto == Some(true) { AUTO_SPACING } else { p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0) },
space_after: if p.space_after_auto == Some(true) { AUTO_SPACING } else { p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0) },
before_auto: p.space_before_auto == Some(true),
after_auto: p.space_after_auto == Some(true),
space_before: p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0),
space_after: p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0),
line_spacing: match p.line_spacing.unwrap_or_default() {
LineSpacing::Multiple(m) => LineSpacing::Multiple(if m.is_finite() { m.clamp(0.06, 132.0) } else { 1.0 }),
LineSpacing::AtLeast(v) => LineSpacing::AtLeast(if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }),
LineSpacing::Exactly(v) => LineSpacing::Exactly(if v.is_finite() { v.clamp(0.7, 1584.0) } else { 12.0 }),
},
contextual_spacing: p.contextual_spacing.unwrap_or(false),
mirror_indents: p.mirror_indents.unwrap_or(false),
keep_next: p.keep_next.unwrap_or(false),
keep_lines: p.keep_lines.unwrap_or(false),
page_break_before: p.page_break_before.unwrap_or(false),
@@ -267,28 +193,17 @@ impl ResolvedPara {
borders: p.borders.filter(Borders::any_visible),
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE),
drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()),
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
kinsoku: p.kinsoku.unwrap_or(true),
word_wrap: p.word_wrap.unwrap_or(true),
overflow_punct: p.overflow_punct.unwrap_or(true),
top_line_punct: p.top_line_punct.unwrap_or(false),
auto_space_de: p.auto_space_de.unwrap_or(true),
auto_space_dn: p.auto_space_dn.unwrap_or(true),
snap_to_grid: p.snap_to_grid.unwrap_or(true),
indent_chars: [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars]
.map(|c| c.filter(|c| c.is_finite()).unwrap_or(0.0).clamp(-MAX_CHARS, MAX_CHARS)),
}
}
}
/// The largest indent in characters (Word allows indents up to 22").
const MAX_CHARS: f32 = 1584.0;
impl StyleSheet {
/// Paragraph properties after defaults and the style chain (without direct formatting).
pub fn para_style_props(&self, style: &str) -> (ParaProps, CharProps) {
@@ -338,13 +253,7 @@ impl StyleSheet {
direct.tabs = Some(merged);
}
base.overlay(&direct);
let mut rp = ResolvedPara::from(&base, &style);
// Indents in characters: measured in the style's font size here; layout measures them in
// the paragraph mark's (`set_char_unit`), which direct formatting may change.
if rp.indent_chars.iter().any(|c| *c != 0.0) {
rp.set_char_unit(self.resolve_char(Some(&style), &CharProps::default()).size);
}
rp
ResolvedPara::from(&base, &style)
}
/// Resolve character formatting for a run in a paragraph of style `para_style`.
@@ -369,8 +278,6 @@ impl StyleSheet {
}
}
}
// Only a character itself has a symbol font of its own (`w:sym`); a style never gives one.
cp.sym_font = None;
let mut direct = c.clone();
direct.style = None;
cp.overlay(&direct);
@@ -382,19 +289,6 @@ impl StyleSheet {
mod tests {
use super::*;
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
/// style never gives one to its text.
#[test]
fn a_style_never_gives_text_a_symbol_font() {
let mut s = StyleSheet::builtin();
if let Some(n) = s.get_mut("Normal") {
n.chr.sym_font = Some("Wingdings".into());
}
assert_ne!(s.resolve_char(None, &CharProps::default()).font, "Wingdings");
let direct = CharProps { sym_font: Some("Wingdings".into()), ..Default::default() };
assert_eq!(s.resolve_char(None, &direct).font, "Wingdings");
}
#[test]
fn heading_resolves() {
let s = StyleSheet::builtin();

Some files were not shown because too many files have changed in this diff Show More