127 Commits
Author SHA1 Message Date
ZacharyZhang-NY fde97a01a9 Version 0.1.2 2026-10-09 16:34:52 -04:00
ZacharyZhang-NY b03c9f2853 Updates: opt-in launch check, Check for Updates…, install after a normal quit, from the Gitea release; dmgs carry an Applications link (UPD-1, REL-1) 2026-10-09 16:34:32 -04:00
ZacharyZhang-NY fa273aff13 Merge remote-tracking branch 'origin/main' 2026-10-09 16:04:54 -04:00
ZacharyZhang-NY 096b7f8fd4 Site: the release comment names no host 2026-10-09 16:04:45 -04:00
ZacharyZhang-NY 9b1b64884b docs: add AGit pull request instructions 2026-10-09 20:03:10 +00:00
ZacharyZhang-NY 623bc085f6 Hosting moves to git.zacharyzhang.com: component dependency, site links, references; GitHub workflows removed 2026-10-09 15:58:26 -04:00
ZacharyZhang-NY 383a1ea467 One copy per app: a second is refused and names the running process (X-03) 2026-10-09 14:57:30 -04:00
ZacharyZhang-NY 6bf5d6c7e6 ElyMark: keys typed before an outside write survive; same text adopts the file; autosave resumes (M-01) 2026-10-09 14:39:58 -04:00
ZacharyZhang-NY c9bfccc91c ElyPDF: the edit tool checks a page's text blocks in one pass (P-04) 2026-10-09 13:16:01 -04:00
ZacharyZhang-NY 4d8d5a6ef5 ElyPDF: text under a covering picture is aimed; a shaky click no longer moves it (P-03) 2026-10-09 13:05:48 -04:00
ZacharyZhang-NY 04040a2a03 ElyPDF: page pictures stay through an edit until they repaint (P-02) 2026-10-09 12:50:11 -04:00
ZacharyZhang-NY 139e8f6250 ElyDoc: mail merge keeps the template chosen with the data; previews are never templates (D-02) 2026-10-09 12:27:13 -04:00
ZacharyZhang-NY 1f4807c81c ElyDoc: closing a tab takes its export or print dialog, no crash (D-01) 2026-10-09 12:06:47 -04:00
ZacharyZhang-NY ec87e28a46 Doc, Table, Show start past unreadable settings; the newest author name signs, restarts too (X-02) 2026-10-09 11:59:26 -04:00
ZacharyZhang-NY 8a915ea354 Quit from any window, Settings too, asks about unsaved tabs in front (X-01) 2026-10-09 11:37:56 -04:00
ZacharyZhang-NY 943411fb1a ElyPDF: an OS permission error shows as an error, never a missing text key (P-01); component library f834f55 2026-10-09 10:58:47 -04:00
ZacharyZhang-NY ab85673eea ElyShow: a deck fits the slide once; a zoom then stays (S-03); per-deck state moves to extra.rs (S-02) 2026-10-09 10:39:45 -04:00
ZacharyZhang-NY f9bf82e3f8 Bug list: fatal and high first, then auto-update and 0.1.2 2026-10-09 10:26:35 -04:00
ZacharyZhang-NY a6d8a1de43 ElyTable: a sheet starts at the left, even before its geometry arrives (T-03) 2026-10-09 10:16:11 -04:00
ZacharyZhang-NY ecf977364b ElyTable: a cell scrolls in by the least amount; a drag never scrolls under the pointer (T-02) 2026-10-09 09:47:10 -04:00
ZacharyZhang-NY c52e1a3b16 Bug list progress file; reviews find bug items 2026-10-09 09:32:01 -04:00
ZacharyZhang-NY 63bf0cc828 Bug list from the static audit (branch claude/gracious-franklin-l5vhbv) 2026-10-09 09:29:06 -04:00
ZacharyZhang-NY 380e6783d6 Tasks: 0.1.1 published 2026-10-09 09:28:58 -04:00
ZacharyZhang-NY e4ca134655 Source archives leave out the internal rules, requirements and task records 2026-10-09 09:26:35 -04:00
ZacharyZhang-NY 4efe14bce7 ElyMark: the probe requires each task box laid out and visible (R2.14) 2026-10-09 09:19:15 -04:00
ZacharyZhang-NY eee3b9e288 ElyTable: the chart search uses its own sheet, scans the sheet as a last resort, and fails cleanly with no room (R2.10) 2026-10-09 09:10:28 -04:00
ZacharyZhang-NY b3fc6c9c82 ElyMark: the probe also requires each task box drawn (R2.14) 2026-10-09 09:01:13 -04:00
ZacharyZhang-NY 8a7e45d901 Version 0.1.1 2026-10-09 08:51:42 -04:00
ZacharyZhang-NY 47b05b52fa Shorten a comment in patch 0041 (R2.10) 2026-10-09 08:51:14 -04:00
ZacharyZhang-NY 81487fb32d ElyTable: the chart search skips merged cells, looks four ways, and never throws (R2.10) 2026-10-09 08:49:39 -04:00
ZacharyZhang-NY 326fe24cba Video: the check compares both renderers at the cut and every exit frame's recordings (R2.3) 2026-10-09 08:23:16 -04:00
ZacharyZhang-NY 35165139a8 ElyShow: the fullscreen check also requires the menu bar and Dock hidden (R2.5) 2026-10-09 08:22:31 -04:00
ZacharyZhang-NY 9a126611a2 ElyMark: the probe checks every task box, ticked or not (R2.14) 2026-10-09 08:09:54 -04:00
ZacharyZhang-NY 511fb8d4eb Tasks: R2.20 and R2.7 done; R2.6, R2.8, R2.12 at the round limit 2026-10-09 07:31:31 -04:00
ZacharyZhang-NY b243515190 ElyTree: per-language presets for diffs; component library b0dbfd4 (R2.12) 2026-10-09 07:31:13 -04:00
ZacharyZhang-NY ff790e31cf ElyTable: figures split at field names; a format's own semicolons stay (R2.8) 2026-10-09 07:27:19 -04:00
ZacharyZhang-NY cc589a07b3 ElyPDF: the old-tile test checks painted area covers the shown page (R2.6); shorter comments in patch 0041 2026-10-09 07:25:55 -04:00
ZacharyZhang-NY 3c6faf9a6e ElyTree: diffs color per language; component library c771248 (R2.12) 2026-10-09 06:53:36 -04:00
ZacharyZhang-NY f4dad1d269 ElyTable: a new chart goes in empty cells beside its data, off the view if need be, and scrolls into view (R2.10) 2026-10-09 06:47:31 -04:00
ZacharyZhang-NY 409805e863 Shorten a comment (R2.5) 2026-10-09 06:43:14 -04:00
ZacharyZhang-NY ecd849601b ElyShow: a real-window check that the show covers the whole screen (R2.5) 2026-10-09 06:43:11 -04:00
ZacharyZhang-NY 65d79295ec ElyMark: short table rows compare padded, as GFM reads them (R2.13) 2026-10-09 06:38:35 -04:00
ZacharyZhang-NY 301890485f Video: one slab list for montage and exit; a render check for the recordings at the cut (R2.3) 2026-10-09 06:36:27 -04:00
ZacharyZhang-NY 5b81c5acf2 Tasks: R2.11 done 2026-10-09 05:56:16 -04:00
ZacharyZhang-NY 6445f3144d Zoom reads the current centring offset, so bunched zooms hold the anchor (R2.7); figures pass kernel values through, tested (R2.8) 2026-10-09 05:56:15 -04:00
ZacharyZhang-NY 2c3f3894dd ElyShow: the large-layer test checks the shape lands past the first piece (R2.20) 2026-10-09 05:55:06 -04:00
ZacharyZhang-NY 03bcbab5ce ElyPDF: only content edits keep old tiles and known sizes; old tiles paint and free with their tab (R2.6) 2026-10-09 05:51:44 -04:00
ZacharyZhang-NY 1bf693f7ab Video: leaving screens share the montage's geometry; renderer tests for names and recordings (R2.3) 2026-10-09 05:19:34 -04:00
ZacharyZhang-NY 44a61443fa ElyMark: tables compare by alignment and cell source, in quotes and lists too; rewritten cells stay raw (R2.13) 2026-10-09 05:15:42 -04:00
ZacharyZhang-NY 844d98559b ElyTree: diff lines color by their file's syntax; component library 5caf5aa (R2.12) 2026-10-09 05:11:22 -04:00
ZacharyZhang-NY db1e889f15 Counts: tests read the real catalogs; the ElyTree summary is tested at 0, 1 and 2 (R2.11) 2026-10-09 05:06:24 -04:00
ZacharyZhang-NY 2537472bd3 Documents: zoom anchors past the centring offset; key zoom keeps the page's left edge (R2.7) 2026-10-09 04:40:12 -04:00
ZacharyZhang-NY 3f567bf4a0 ElyTable: selection figures show as the kernel formats them for the cell (R2.8) 2026-10-09 04:36:18 -04:00
ZacharyZhang-NY b870ba9d41 ElyPDF: old tiles stay under their page through edits and rescales until new ones land (R2.6) 2026-10-09 04:34:51 -04:00
ZacharyZhang-NY 50e18d835e ElyShow: a layer comes in 16 MiB pieces; closing a deck drops its waiting layers (R2.20) 2026-10-09 04:28:04 -04:00
ZacharyZhang-NY b591aa8625 ElyTable: a new chart lands beside its data (R2.10) 2026-10-09 04:02:32 -04:00
ZacharyZhang-NY efe5d4fc5d ElyShow: the show is native fullscreen off macOS, simple fullscreen on it (R2.5) 2026-10-09 04:02:29 -04:00
ZacharyZhang-NY c77bd2a281 ElyMark: task boxes are drawn at text size on the first line (R2.14) 2026-10-09 03:57:56 -04:00
ZacharyZhang-NY f28768634f ElyMark: a table stays a table whatever its spacing; edits keep the document's table style (R2.13) 2026-10-09 03:50:52 -04:00
ZacharyZhang-NY 7049a6a4aa Tasks: R2.9 done 2026-10-09 03:49:29 -04:00
ZacharyZhang-NY be33bb28aa Patch 0040: blank line before the diff 2026-10-09 03:44:29 -04:00
ZacharyZhang-NY cc67055ccc ElyTable: a filtered range keeps its rows only for a cell inside it (R2.9) 2026-10-09 03:43:29 -04:00
ZacharyZhang-NY e85884212b Tasks: R2.17 done 2026-10-09 03:39:03 -04:00
ZacharyZhang-NY c5e19eb0b5 ElyTree: prose diffs read plain; component library 7f2ac3e (R2.12) 2026-10-09 03:28:39 -04:00
ZacharyZhang-NY 5a6c17757e ElyPDF: the document's font reads codes as text extraction does, partial ToUnicode included (R2.17) 2026-10-09 03:22:39 -04:00
ZacharyZhang-NY 0ce468a3ad ElyTable: under LOK an AutoFilter range keeps its rows through list, apply, toggle, undo and reopen (R2.9) 2026-10-09 03:20:07 -04:00
ZacharyZhang-NY f4c1a1665b ElyPDF: the document's font writes through its own object and codes; shared glyphs keep their letters (R2.17) 2026-10-09 03:17:08 -04:00
ZacharyZhang-NY fbcf654812 Review: R2.17 round 1 2026-10-09 03:07:20 -04:00
ZacharyZhang-NY 6f90a7dc53 Counts read in the singular for one thing; parts build two-count messages (R2.11) 2026-10-09 03:07:13 -04:00
ZacharyZhang-NY d4c5f90c87 ElyTable: an AutoFilter on marked rows keeps the total below out (R2.9) 2026-10-09 02:53:55 -04:00
ZacharyZhang-NY 69b299f2db Tasks: R2.16 done 2026-10-09 02:50:06 -04:00
ZacharyZhang-NY bb3120ebe4 Shorten a comment (R2.8) 2026-10-09 02:40:34 -04:00
ZacharyZhang-NY e1950f9121 ElyTable: plain figures show ten significant digits (R2.8) 2026-10-09 02:40:31 -04:00
ZacharyZhang-NY 1cf81dcb44 Sheet scroll checks run on a real sheet with its geometry (R2.16) 2026-10-09 02:39:47 -04:00
ZacharyZhang-NY 05c3eab2f8 Shorten a comment (R2.7) 2026-10-09 02:38:50 -04:00
ZacharyZhang-NY 1b1148a410 ElyDoc: a page stays centred and zoom stays on the page; only a sheet grows past its size (R2.7) 2026-10-09 02:38:46 -04:00
ZacharyZhang-NY 9fe28f072b ElyPDF: an edit keeps the page drawn until its repaint lands (R2.6) 2026-10-09 02:34:32 -04:00
ZacharyZhang-NY 5e2cf83da8 Video: app names clear before the flatten; montage screens play while they lift (R2.3) 2026-10-09 02:31:47 -04:00
ZacharyZhang-NY 48e83a9b45 ElyShow: show layers come one per reply, so large slides play (R2.20) 2026-10-09 02:31:47 -04:00
ZacharyZhang-NY 56855acfb4 ElyTable: zoom and DPI keep the scroll in print twips; drag and zoom tests far down a sheet (R2.16) 2026-10-09 02:29:46 -04:00
ZacharyZhang-NY eb844cc72c ElyShow: the show hides the menu bar and Dock (R2.5) 2026-10-09 02:23:57 -04:00
ZacharyZhang-NY a3d29bec81 Shorten a comment (R2.17) 2026-10-09 02:20:41 -04:00
ZacharyZhang-NY 371b1e81c8 ElyPDF: write in a document's subset font through the page's own glyph codes (R2.17) 2026-10-09 02:20:36 -04:00
ZacharyZhang-NY 286631daf9 Tasks: R2.4 done 2026-10-09 02:16:34 -04:00
ZacharyZhang-NY ee98f6d7c0 ElyShow: test paints slide two's pixels after a switch (R2.4) 2026-10-09 02:11:58 -04:00
ZacharyZhang-NY be31260753 ElyShow: switching slides repaints the canvas (R2.4) 2026-10-09 02:05:45 -04:00
ZacharyZhang-NY 0d529e1b0d ElyTable: clicks and drags land on the cell under them far down a sheet (R2.16) 2026-10-09 02:04:13 -04:00
ZacharyZhang-NY 148885248b Tasks: R2.2 done 2026-10-09 01:47:15 -04:00
ZacharyZhang-NY 78a0158956 Site: names use the SVG's screen transform; resize tests use CSS size (R2.2) 2026-10-09 01:45:00 -04:00
ZacharyZhang-NY 4f4bf5c35c Tasks: R2.18 done 2026-10-09 01:43:53 -04:00
ZacharyZhang-NY c078d3d0a7 Site: drop dead screenshot text, screen() and images (R2.18) 2026-10-09 01:41:23 -04:00
ZacharyZhang-NY 92702273df Site: downloads go to the GitHub release page (R2.18) 2026-10-09 01:37:38 -04:00
ZacharyZhang-NY cf04a100ff Tasks: R2.15 done 2026-10-09 01:36:29 -04:00
ZacharyZhang-NY 034cbfce02 Tasks: user-reported R2.16–R2.19 2026-10-09 01:36:20 -04:00
ZacharyZhang-NY f6f4b95108 Inert step refuses an unreadable plist; guard runs it on a fake bundle (R2.15) 2026-10-09 01:34:07 -04:00
ZacharyZhang-NY b5d490c49a Inert step tolerates only 'not registered'; guard fails on query or plist errors; test asserts the tile rejection (R2.15) 2026-10-09 01:31:08 -04:00
ZacharyZhang-NY 1745ee237d Shorten a comment (R2.15) 2026-10-09 01:23:13 -04:00
ZacharyZhang-NY 466afe20a0 No LibreOffice in Open With: built bundle claims nothing, no image is mounted (R2.15) 2026-10-09 01:23:06 -04:00
ZacharyZhang-NY 5a36ffc272 Site: names placed from the rest pose, follow model resizes; two-axis tests with motion (R2.2) 2026-10-09 01:15:27 -04:00
ZacharyZhang-NY 41fe64c74d Site: shown names follow the camera; touch tests fail on console and request errors (R2.2) 2026-10-09 01:11:59 -04:00
ZacharyZhang-NY a9f093ca26 Tasks: R2.1 done 2026-10-09 01:10:19 -04:00
ZacharyZhang-NY f5455ecd36 Site: contrast check counts element and ancestor opacity (R2.1) 2026-10-09 01:08:06 -04:00
ZacharyZhang-NY 9df123ff94 Site: alpha-aware contrast check; test the open steps (R2.1) 2026-10-09 01:05:26 -04:00
ZacharyZhang-NY 5162692902 Site: source archives, macOS 15 open steps, contrast test in both themes (R2.1) 2026-10-09 01:02:15 -04:00
ZacharyZhang-NY aeeccb4696 Tasks: R2 fixes found after release 2026-10-09 01:01:33 -04:00
ZacharyZhang-NY ff92f26af0 Review notes for site items S1–S5 2026-10-09 00:37:25 -04:00
ZacharyZhang-NY 4d8bd05633 Video: voxel titles, new opening with world turn, app punch-ins and callouts, six-screen montage, props sequence; site carries the new film 2026-10-09 00:36:35 -04:00
ZacharyZhang-NY 70cd57bd5d Site: scroll tour with live clips, beat-reactive desk and speaker, road playground, night wave, film on an isometric screen 2026-10-09 00:33:25 -04:00
ZacharyZhang-NY b6b882a8a1 Video: final edit, checks; V4–V6 done 2026-10-09 00:06:21 -04:00
ZacharyZhang-NY f017ef38c4 Site: e2e for language switch, link targets and revealed downloads 2026-10-09 00:03:57 -04:00
ZacharyZhang-NY 0a01845db9 Site: props text on one level, GitHub links 2026-10-08 23:59:12 -04:00
ZacharyZhang-NY d0d890359b agents.md: site stack, directory and deploy; site task list 2026-10-08 23:58:20 -04:00
ZacharyZhang-NY 07719a1fd3 Site: app regions with screenshots, props, downloads, English and Chinese, narrow layout; hit layer follows draw order 2026-10-08 23:57:34 -04:00
ZacharyZhang-NY 29e4e7f70f Site: separate hover, focus and press; still hit areas; one-cell lift; e2e tests 2026-10-08 23:47:27 -04:00
ZacharyZhang-NY 0f62a36182 Site: buildings lift on hover and focus, sink on press; ordered keyboard links 2026-10-08 23:40:54 -04:00
ZacharyZhang-NY 366f7529ba Site: S3 passed review; V3 recordings done 2026-10-08 23:39:15 -04:00
ZacharyZhang-NY 6619e6e1fb Site: close road gaps, ElyMark exports to PDF, test road links; recording fixes 2026-10-08 23:35:05 -04:00
ZacharyZhang-NY 3b75617599 Site: hero model of six apps, roads, beat-timed growth 2026-10-08 23:29:21 -04:00
ZacharyZhang-NY b991d59b21 Site: isometric geometry; comment checks scan JS, TS and CSS by tokens 2026-10-08 23:26:32 -04:00
ZacharyZhang-NY 570bb176e8 Comment check: multi-line block comments in .ts and .css 2026-10-08 23:20:51 -04:00
ZacharyZhang-NY e96bea98d3 Site: shorter test comment; comment check covers the site 2026-10-08 23:17:01 -04:00
ZacharyZhang-NY 4b76473fde Site: order only blocks whose projections overlap; ordering tests 2026-10-08 23:13:19 -04:00
ZacharyZhang-NY 623554fa77 Site: isometric blocks in SVG with an outline under the faces; separating-axis draw order 2026-10-08 23:07:10 -04:00
ZacharyZhang-NY 01397eb1e5 Site: isometric geometry, blocks shaded on three faces 2026-10-08 22:59:44 -04:00
ZacharyZhang-NY 1178aaa4f2 Site: Vite 8 skeleton for Workers static assets; task lists 2026-10-08 22:52:25 -04:00
ZacharyZhang-NY ca6782d37d Add the project rules, requirements and task records 2026-10-08 22:32:28 -04:00
ZacharyZhang-NY 570671abc3 Release workflow runs for a chosen tag 2026-10-08 22:23:14 -04:00
621 changed files with 20003 additions and 830 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Internal records stay out of source archives.
agents.md export-ignore
CLAUDE.md export-ignore
PRD.md export-ignore
tasks export-ignore
-77
View File
@@ -1,77 +0,0 @@
# Builds the patched engines once; release.yml downloads them.
name: engines
on:
workflow_dispatch:
permissions:
contents: write
jobs:
macos:
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
arch: arm64
- runner: macos-15-intel
arch: x86_64
runs-on: ${{ matrix.runner }}
timeout-minutes: 355
env:
GH_TOKEN: ${{ github.token }}
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 6G
steps:
- uses: actions/checkout@v4
- name: Key
id: key
run: echo "key=$(scripts/engines-key.sh)" >> "$GITHUB_OUTPUT"
- name: Already built?
id: done
run: |
if gh release view "engines-${{ steps.key.outputs.key }}" --json assets \
--jq '.assets[].name' 2>/dev/null | grep -qx "engines-macos-${{ matrix.arch }}.tar.zst"; then
echo "built=true" >> "$GITHUB_OUTPUT"
fi
- name: Tools
if: steps.done.outputs.built != 'true'
run: brew install make gnu-sed bison flex gettext autoconf automake libtool ccache nasm zstd
- name: Compiler cache
if: steps.done.outputs.built != 'true'
uses: actions/cache/restore@v4
with:
path: .ccache
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
restore-keys: ccache-macos-${{ matrix.arch }}-
- name: Upstream sources
if: steps.done.outputs.built != 'true'
run: scripts/fetch-references.sh
- name: LibreOffice (patched)
if: steps.done.outputs.built != 'true'
run: scripts/build-libreoffice.sh
- name: MuPDF (patched), Git, git-lfs, OCR data, CEF
if: steps.done.outputs.built != 'true'
run: |
scripts/build-mupdf.sh
scripts/build-git.sh
scripts/fetch-git-lfs.sh
scripts/fetch-tessdata.sh
scripts/fetch-cef.sh
- name: Keep the compiler cache, even after a timeout
if: always() && steps.done.outputs.built != 'true'
uses: actions/cache/save@v4
with:
path: .ccache
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
- name: Publish
if: steps.done.outputs.built != 'true'
run: |
archive=engines-macos-${{ matrix.arch }}.tar.zst
tar -C engines -cf - lo-src/instdir mupdf-build mupdf-src git git-src/COPYING git-src/LGPL-2.1 cef tessdata |
zstd -19 -T0 -o "$archive"
tag=engines-${{ steps.key.outputs.key }}
gh release view "$tag" >/dev/null 2>&1 ||
gh release create "$tag" --prerelease --title "$tag" --notes "Pinned, patched engines; built by engines.yml."
gh release upload "$tag" "$archive" --clobber
-55
View File
@@ -1,55 +0,0 @@
# On a version tag: macOS apps attached to its release.
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write
jobs:
macos:
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
arch: arm64
- runner: macos-15-intel
arch: x86_64
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- name: Engines
run: |
brew install zstd
tag=engines-$(scripts/engines-key.sh)
gh release download "$tag" --pattern "engines-macos-${{ matrix.arch }}.tar.zst" ||
{ echo "::error::no $tag for ${{ matrix.arch }}; run the engines workflow first"; exit 1; }
mkdir -p engines
zstd -dc "engines-macos-${{ matrix.arch }}.tar.zst" | tar -C engines -xf -
- name: Upstream sources and ElyMark's page
run: |
scripts/fetch-references.sh
scripts/build-mark-content.sh
- name: Tests that need no window
run: |
tests/scripts/types.sh
tests/scripts/icons.sh
python3 tests/scripts/comments.py
- name: Packages
run: packaging/macos/release.sh 1
- name: Attach to the release
if: startsWith(github.ref, 'refs/tags/')
run: |
version=${GITHUB_REF_NAME}
gh release view "$version" >/dev/null 2>&1 || gh release create "$version" --title "Ely Office $version" --notes "Built by release.yml."
gh release upload "$version" dist/*.dmg --clobber
if [ "${{ matrix.arch }}" = arm64 ]; then
gh release upload "$version" dist/ElyOffice-*-source.tar.gz dist/ElyOffice-*-upstream-source.tar.gz --clobber
fi
+2 -8
View File
@@ -7,18 +7,12 @@ node_modules/
.DS_Store
.ruff_cache/
# Internal working files, kept local
/agents.md
/CLAUDE.md
/PRD.md
/tasks/
# Local only
/tasks/reviews/.pane
/.pi/
/markdown-sample.md
/docs/engineering-risks.md
/docs/qa.md
/docs/acceptance.md
/docs/test-map.md
/scripts/review.sh
/scripts/test-map.py
/tests/scripts/agents_md.py
/assets/papirus_icons-master.zip
+1
View File
@@ -0,0 +1 @@
@agents.md
+15
View File
@@ -0,0 +1,15 @@
# Contributing
## Opening a pull request on git.zacharyzhang.com
Forking is disabled on this server. Open pull requests with AGit instead:
1. Register an account at https://git.zacharyzhang.com
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
```bash
git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
```
3. To update the pull request, push again with the same `topic`.
Generated
+18 -17
View File
@@ -1919,7 +1919,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "ely-agent"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"ely-platform",
"ely-ui",
@@ -1927,7 +1927,7 @@ dependencies = [
[[package]]
name = "ely-doc"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"anyhow",
"csv",
@@ -1948,7 +1948,7 @@ dependencies = [
[[package]]
name = "ely-files"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"aes-gcm",
"ely-platform",
@@ -1964,7 +1964,7 @@ dependencies = [
[[package]]
name = "ely-gpui-component"
version = "0.1.1"
source = "git+https://github.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=69265b4fd4da888e2d8e1268021af4d358e67afb#69265b4fd4da888e2d8e1268021af4d358e67afb"
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=5d6efd46ec0c93b381ec7578561a1273c366025b#5d6efd46ec0c93b381ec7578561a1273c366025b"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -2001,14 +2001,14 @@ dependencies = [
[[package]]
name = "ely-ipc"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"log",
]
[[package]]
name = "ely-lok"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"base64 0.23.1",
"ely-gpui-component",
@@ -2028,7 +2028,7 @@ dependencies = [
[[package]]
name = "ely-mail"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"ely-platform",
"ely-ui",
@@ -2036,7 +2036,7 @@ dependencies = [
[[package]]
name = "ely-mark"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"anyhow",
"base64 0.23.1",
@@ -2058,7 +2058,7 @@ dependencies = [
[[package]]
name = "ely-office"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"anyhow",
"directories",
@@ -2078,7 +2078,7 @@ dependencies = [
[[package]]
name = "ely-pdf"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"ely-files",
"ely-gpui-component",
@@ -2094,7 +2094,7 @@ dependencies = [
[[package]]
name = "ely-pdf-engine"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"cc",
"ely-ipc",
@@ -2110,7 +2110,7 @@ dependencies = [
[[package]]
name = "ely-platform"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"anyhow",
"arboard",
@@ -2143,7 +2143,7 @@ dependencies = [
[[package]]
name = "ely-show"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"ely-files",
"ely-gpui-component",
@@ -2162,7 +2162,7 @@ dependencies = [
[[package]]
name = "ely-table"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"ely-gpui-component",
"ely-lok",
@@ -2178,7 +2178,7 @@ dependencies = [
[[package]]
name = "ely-tree"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"ely-files",
"ely-gpui-component",
@@ -2196,7 +2196,7 @@ dependencies = [
[[package]]
name = "ely-ui"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"anyhow",
"directories",
@@ -2207,13 +2207,14 @@ dependencies = [
"gpui-pre-platform",
"log",
"notify",
"semver",
"serde_json",
"tempfile",
]
[[package]]
name = "ely-web-content"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"anyhow",
"cef",
+2 -2
View File
@@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"]
exclude = ["engines", "reference"]
[workspace.package]
version = "0.1.0"
version = "0.1.2"
edition = "2024"
rust-version = "1.95"
license = "AGPL-3.0-or-later"
@@ -17,7 +17,7 @@ encoding_rs = "0.8"
jiff = "0.2"
anyhow = "1"
directories = "6"
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "69265b4fd4da888e2d8e1268021af4d358e67afb" }
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "5d6efd46ec0c93b381ec7578561a1273c366025b" }
env_logger = "0.11"
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] }
+944
View File
@@ -0,0 +1,944 @@
# Ely Office 产品需求文档
版本:1.0
日期:2026-10-01
状态:产品与工程实施基线
目标平台:Linux、Windows、macOS
产品形态:开源、离线优先、可独立安装和运行的桌面应用套件
本文中的“必须”是发布要求。“默认”是初始设置,用户可以按文中规定修改。功能表中的所有条目均属于首个正式版本,不代表分阶段计划。性能数值是验收目标,不是已有测试结果。第三方项目提供的能力与 Ely 已完成的能力分开记录。
## 1. 产品定义
Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制作演示、编辑 PDF、收发邮件、管理 Git 仓库、编辑 Markdown 和管理 Agent 会话。
办公文件保存在用户选择的目录。应用不要求注册 Ely 账号,不要求上传文件,不依赖在线文档服务器。全部应用使用 Ely GPUI Components 的设计体系。编辑、解析、计算和协议处理复用现有项目,不重写专业内核。
### 1.1 应用构成
| 应用 | 职责 | 内核或后端基线 | 主要数据 |
| --- | --- | --- | --- |
| ElyDoc | 分页文档编辑 | LibreOfficeKit,简称 LOK | DOCX、ODT |
| ElyTable | 电子表格编辑与计算 | LOK | XLSX、ODS、CSV |
| ElyShow | 演示文稿编辑与放映 | LOK | PPTX、ODP |
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
| ElyMail | 邮件收发、本地存储和搜索 | MailCore 2,加本地同步与存储 | 邮件、附件、草稿 |
| ElyTree | Git 图形化版本管理 | Git CLI | 本地 Git 仓库 |
| ElyMark | 本地 Markdown 编辑 | Milkdown 与 CodeMirror 6;参考 MarkText、ColaMD 的编辑与文件处理方式 | Markdown 与本地附件 |
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 编辑框架、本地 HTML 宿主和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
### 1.2 固定决策
1. 首个正式版交付全部八个应用及本文定义的功能,不发布只有入口和占位界面的套件。
2. Doc、Table、Show 共用 LOK 适配实现。PDF 使用 MuPDF。不开设替代内核路线。
3. GPUI 负责应用界面。专业内容区域可以使用内核渲染结果或本地 Web 内容区域,但外观和行为必须遵守 Ely 规范。
4. 应用可以独立启动、更新和卸载。安装 ElyMark、ElyTree 或 ElyAgent 不得强制安装 LOK。
5. 用户文件采用通用格式。应用数据库只保存索引、配置和运行记录,不取代用户文件。
6. 不建设云端文档平台、微服务系统、统一业务数据库或跨应用 Agent 编排系统。
### 1.3 成功标准
用户在断网电脑上安装发行包后,可以创建、修改、保存、重新打开和打印本文范围内的本地文件。文件中的受支持内容不丢失。界面不会切回上游产品的工具栏或高级设置窗口。邮件、远程 Git 和远程模型连接失败时,本地工作仍可继续。
“对标 Office”指完成本文列出的日常工作,不代表兼容微软所有历史格式、宏、插件和企业服务。不以组件数量、文件扩展名数量或界面截图代替验收。
## 2. 用户与工作场景
面向处理本地文件的个人、开发者和小团队。用户可能只安装一个应用,也可能安装整套产品。首次使用不要求建立工作区、创建账号或完成教程。
| 场景 | 用户操作 | 完成条件 |
| --- | --- | --- |
| 审阅合同 | 打开 DOCX,修改条款,添加批注,保存并导出 PDF | 原有表格、编号、页眉页脚和修订记录按支持范围保留 |
| 整理数据 | 导入 CSV,编辑公式,筛选、汇总并绘制图表 | 导入规则可检查,计算结果可复现,导出不静默丢失工作表 |
| 制作汇报 | 修改 PPTX 母版、图文和备注,在外接屏幕放映 | 编辑对象仍可修改,观众屏与演讲者视图分离 |
| 处理 PDF | 修改文字对象、整理页面、填写表单并删除敏感内容 | 保存后的 PDF 可在独立阅读器打开,删除内容不能从输出文件恢复 |
| 离线处理邮件 | 阅读已缓存邮件,检索附件,写回复 | 草稿和发件队列落盘;联网后的发送状态有证据 |
| 管理仓库 | 查看差异、分块暂存、提交、合并并处理冲突 | Git 仓库与终端操作结果一致,危险操作先说明影响 |
| 编写 Markdown | 编辑本地文档,插入图片,与外部编辑器共同使用 | 未修改内容不被无故重写,外部变更不覆盖未保存内容 |
| 管理 Agent | 配置后端,在多个目录启动会话,查看执行和产物 | 会话状态与后端一致,重启后的恢复能力如实显示 |
## 3. 产品范围与离线边界
### 3.1 离线能力
| 应用 | 无网络时必须可用 | 需要连接时才可执行 |
| --- | --- | --- |
| Doc、Table、Show | 本地编辑、搜索、保存、导出、打印、放映 | 用户主动更新外部链接或打开远程资源 |
| PDF | 本地编辑、OCR、批注、表单、页面处理、导出 | 在线证书吊销查询、时间戳服务等外部请求 |
| Mail | 读取已缓存内容、搜索、写草稿、管理待发送邮件 | 首次授权、收取新邮件、下载未缓存内容、发送 |
| Tree | 查看已有历史、差异、暂存、提交、分支、合并、变基 | 访问远程仓库、下载尚未缓存的 LFS 对象 |
| Mark | 编辑、预览、搜索、公式与图表渲染、PDF/HTML 导出 | 用户主动加载远程图片或打开外部链接 |
| Agent | 查看本地配置和历史;运行依赖均已安装的本地后端 | 远程模型、远程工具及其授权 |
应用资源、帮助、默认模板、Markdown 渲染资源和 PDF 中英文 OCR 语言数据随对应发行包提供。不从 CDN 加载脚本、字体或样式。断网不能阻止应用启动,也不能触发登录弹窗。
本地模型及第三方 Agent 不强制打包。离线使用它们的前提是用户已安装对应程序、模型和依赖。应用必须检查这些条件,不能把“能打开会话界面”显示成“模型可离线运行”。
### 3.2 不纳入产品范围
不建设实时多人协同、云盘、在线 Office 服务、组织管理后台、应用商店、计费系统、项目管理系统或完整日历产品。
不自研文字排版、公式计算、PDF 解析、邮件协议、Git 存储、模型推理和通用 Agent 执行框架。不承诺执行 VBA、ActiveX、COM 插件、Power Query、Power Pivot 或微软专有协作功能。
不提供远程桌面、跨电脑 Agent 调度、自动代发邮件、自动跨应用修改文件或无限制后台任务。用户可通过系统文件和明确的“用其他应用打开”操作连接应用。
这些是产品边界,不是推迟交付的清单。
## 4. 平台与发行
### 4.1 支持目标
以下版本是本项目的支持目标,不是上游库已经通过验证的结论。
| 平台 | 首版架构与验收环境 | 发行方式 |
| --- | --- | --- |
| Windows | Windows 11,x86_64 | 离线安装程序;支持按用户安装 |
| macOS | macOS 14 及以上,Apple Silicon 与 Intel | 按架构提供签名和公证后的应用包 |
| Linux | x86_64;Ubuntu 24.04 LTS;固定日期的 Arch/Omarchy 测试快照 | AppImage;提供原生包构建说明 |
Linux 必须覆盖 Wayland 和 X11。macOS 两种架构分别测试,不以另一架构模拟运行代替。Windows ARM、Linux ARM、移动端和浏览器版不在首版支持清单。同时,针对 Omarchy Linux,需要适配 Omarchy Linux 的 Theme Sync 功能
必须发布图形后端、系统库和最低运行环境说明。不承诺任意虚拟机或无图形加速环境可运行;无法初始化图形后端时显示原因,不留下空白窗口。
### 4.2 安装与更新
全套发行包包含八个应用。每个应用也有独立发行包,并附带其所需运行库。用户不必安装开发工具、Node.js、Python、Docker 或文档服务器。开发和构建依赖不受此限制。
各应用共享同一套发布版本与设计规范。独立安装包自带所需运行库,允许必要的重复打包,不建设全局运行库管理器。升级一个应用不得替换其他应用依赖的内核版本。
自动检查更新默认关闭。提供手动检查、下载和离线安装。更新前要求保存编辑内容;更新失败可继续使用原版本。Linux 包管理器安装的版本不绕过包管理器自行覆盖文件。
卸载应用保留用户文档。删除缓存、账号或本地历史必须单独确认。不得把用户选择的工作目录视为应用缓存删除。
## 5. 统一 UI/UX
### 5.1 组件使用
应用窗口、菜单、导航、标签页、工具栏、属性面板、对话框、通知和设置使用 Ely GPUI Components。优先复用现有组件。新增通用交互回到组件库实现,不在八个应用里复制。
仓库已有文档、Git、邮件、Agent 等组件分类,但任务清单中的 `host`、`prove` 等标签说明仍有宿主数据或接入要求。组件展示与应用能力分开验收。[S1][S2]
不使用上游 Office 的窗口、菜单和侧栏作为正式界面。不得用颜色覆盖掩盖两套交互。平台原生文件选择器、打印对话框和凭据授权窗口属于允许的系统界面。
### 5.2 布局与外观
采用同一套浅色、深色和跟随系统主题。沿用组件库的颜色、字体、间距、圆角、边框和图标 token,不新建每应用主题。不使用渐变、霓虹、装饰性玻璃效果、大面积品牌色或多层卡片。
窗口主体按需要使用标题与标签区、左侧导航、中央内容区、右侧属性区和底部状态区。无内容的侧栏不占位。属性区只显示当前选择对象的属性。专业工具不同,但相同操作的位置、名称和图标相同。
ElyMark 默认只显示必要的标签、文件和编辑入口。ElyMail 使用文件夹、邮件列表和正文。ElyTree 使用仓库导航、提交或改动列表与差异区。不得为了布局相同而强迫所有应用常驻三栏。
界面主题不修改文档本身的字体、背景、单元格填色或打印颜色。深色阅读显示与输出内容分开。
### 5.3 共用交互
| 编号 | 要求 | 验收方式 |
| --- | --- | --- |
| UX-01 | 文件菜单中的新建、打开、保存、另存为、导出和打印使用统一命名 | 在各适用应用逐项检查 |
| UX-02 | 相同操作使用相同快捷键语义;macOS 使用 Command,其他平台使用 Ctrl | 从菜单和快捷键得到相同结果 |
| UX-03 | Tab 顺序可预测,焦点可见;Esc 关闭当前浮层,不连带退出文档 | 仅用键盘完成主要工作流程 |
| UX-04 | 禁用操作说明原因;混合选择显示“混合”,不冒用首个对象属性 | 多选不同格式内容后检查属性栏 |
| UX-05 | 长任务显示范围、状态、错误和可用的取消操作 | 导出、OCR、同步、Git 操作不阻塞窗口 |
| UX-06 | 失败提示保留用户输入,并提供重试或另存路径 | 模拟权限不足、断网和磁盘不足 |
| UX-07 | 危险操作说明具体对象和影响,不只显示“确定吗” | 检查删除、覆盖、硬重置和内容删除 |
| UX-08 | 支持简体中文与英文;错误正文不用未解释的内部错误码替代 | 切换语言后检查主要界面 |
| UX-09 | 中文输入法组合输入、候选框、撤销与跨区域焦点一致 | 三端分别测试拼音输入和中英文混排 |
| UX-10 | 多屏缩放变化不造成点击偏移、选区漂移或浮层遮挡 | 100%、150%、200% 缩放下跨屏拖动 |
| UX-11 | 正文、标签、错误和状态不只通过颜色表达 | 检查灰度显示和高对比主题 |
| UX-12 | 使用系统辅助技术所需的名称、角色、状态和文本信息 | 三端完成键盘与屏幕阅读器检查 |
同一窗口只允许一个活动编辑焦点。菜单操作可作用于编辑器当前选区,但不得先清除选区。内核快捷键与 GPUI 快捷键不得重复执行同一操作。
## 6. 文件、保存与恢复
### 6.1 文件会话
Doc、Table、Show、PDF 和 Mark 的每个打开文件都有独立会话。会话记录文件位置、最后加载的文件指纹、编辑修订号、保存状态、光标或选区、缩放和恢复副本位置。
同一个文件再次打开时聚焦已有会话,或在用户要求下打开明确标注的只读视图。不允许两个窗口在不知情的情况下交替覆盖同一文件。
| 编号 | 要求 |
| --- | --- |
| FILE-01 | 新建文件在首次保存前没有隐含目标路径;关闭前处理未保存内容 |
| FILE-02 | 显示加载中、已保存、未保存、保存中、只读、外部已修改、保存失败等状态 |
| FILE-03 | 保存任务记录对应修订号;旧修订保存完成不得把更新的编辑标为已保存 |
| FILE-04 | 写入临时文件并检查写入结果,再替换目标;失败保留原文件与未保存内容 |
| FILE-05 | 保存前检查外部修改;冲突提供重新加载、保留本地副本和比较,不静默覆盖 |
| FILE-06 | 导出默认生成独立文件,不改变正在编辑文件的格式和路径 |
| FILE-07 | 复制、粘贴、拖放和附件导入失败时不删除原始数据 |
| FILE-08 | 保存、导出、打印都针对可识别的文档修订,不输出一半新一半旧的内容 |
| FILE-09 | 路径支持空格、中文、非 ASCII 字符和长文件名;保留原有大小写 |
| FILE-10 | 对损坏、加密、缺少字体和不支持的内容给出可执行提示 |
本地文件系统采用原子替换能力。网络盘、云盘占位文件或不支持原子替换的目标必须保留上一份文件,并提示相应限制,不把本地原子保存保证套用于所有存储介质。
### 6.2 默认保存行为
Doc、Table、Show、PDF 默认显式保存,后台只生成恢复副本,不擅自覆盖源文件。用户可以对普通未签名文件启用自动保存。检测到外部变更或兼容风险后,停止自动覆盖并提示。
ElyMark 对已有路径文件默认自动保存:输入停止 1 秒后提交保存任务。无路径文件先生成恢复副本,首次保存仍由用户选择位置。自动保存失败后保持未保存状态。
恢复副本默认每 10 秒生成一次,采用后台任务。同一文档只允许一个恢复任务在运行。大文件无法完成时显示最后成功恢复时间,不宣称已经持久化。保留最近 20 个恢复点,最多 7 天;用户可更改容量上限。恢复内容不得同步到外部服务。
关闭时提供保存、放弃和取消。显式放弃后记录结果,不在下次启动时把它误判为崩溃恢复。删除敏感内容相关临时文件遵守 PDF 章节的单独规则。
### 6.3 崩溃与恢复
内核崩溃不得带走其他应用。当前应用显示故障、最近一次恢复时间和重新打开入口。恢复生成新副本,不先覆盖源文件。
能重新打开恢复文件,不等于恢复了崩溃前的全部撤销历史。界面必须区分内容恢复、光标恢复和历史恢复。未具备后端支持时不宣称三者全部恢复。
## 7. LOK 共用接入要求
LOK 提供 C/C++ 接口、分块渲染、输入事件、编辑命令和回调。相关编辑 API 中存在不稳定接口,必须锁定版本并维护回归测试。[S3][S4]
LOK 负责文档模型、布局、计算、内容编辑与文件转换。GPUI 负责操作界面与显示内核输出。不得先转成另一份简单 JSON,再用组件库重新实现页面、表格或幻灯片编辑。
| 编号 | 要求 |
| --- | --- |
| LOK-01 | 使用随应用交付的固定 LOK 构建,隔离用户已安装的 LibreOffice 配置 |
| LOK-02 | 每项界面操作有明确的内核命令、参数、结果和状态回调映射 |
| LOK-03 | 处理内容区域失效通知,只更新必要图块;旧修订的画面不得覆盖新画面 |
| LOK-04 | 统一转换文档坐标、视图缩放、滚动和平台 DPI;输入与选区使用同一坐标规则 |
| LOK-05 | 字体、字号、样式、选择对象、修改状态和撤销可用性以内核为准 |
| LOK-06 | 复制粘贴覆盖纯文本、富文本、图片和表格;不只实现键盘事件转发 |
| LOK-07 | 中文输入法包含组合开始、更新、提交和取消;不拆成普通按键模拟输入 |
| LOK-08 | 页面设置、图表、样式、批注等需要的对话框由 Ely 实现;上游原生对话框不得泄露 |
| LOK-09 | 保存与导出只通过内核支持的过滤器;错误码不能被当成成功 |
| LOK-10 | 关闭文档释放内核对象、图块和临时文件;不终止其他应用的会话 |
| LOK-11 | 不执行宏和自动更新外部链接;打开后明确显示这些内容的处理状态 |
| LOK-12 | 维护功能到接口的映射表和三端测试,不以“LOK 本身支持”作为交付证据 |
图块渲染不自动提供完整无障碍文本树。辅助技术需要的文档文本、光标和选择信息必须单独接入。无法完成某项必需交互时属于发布阻断问题,不以只读截图代替编辑。
## 8. ElyDoc
### 8.1 定位与布局
用于合同、报告、方案、说明书和信函。默认采用分页视图。左侧放置大纲、搜索与页面导航,中央显示文档,右侧放置文字、段落、页面、对象或审阅属性。
新建默认格式为 DOCX,用户可以改为 ODT。已有文件保留原格式。格式转换必须由用户触发。
### 8.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| DOC-01 | 新建与模板 | 提供空白、报告和信函模板;支持从本地模板新建,不修改模板原件 |
| DOC-02 | 文字编辑 | 支持输入、选择、复制粘贴、撤销重做、字体、字号、粗斜体、上下标、颜色和高亮 |
| DOC-03 | 段落 | 支持对齐、缩进、行距、段前段后、制表位、项目符号与多级编号 |
| DOC-04 | 样式 | 支持应用、创建和修改字符及段落样式;标题样式驱动大纲和目录 |
| DOC-05 | 页面与分节 | 支持纸张、方向、页边距、分页符、分节、分栏;分页由内核负责 |
| DOC-06 | 页眉页脚 | 支持页码、首页不同、奇偶页不同;分节关系可查看和修改 |
| DOC-07 | 表格 | 支持插入、行列调整、合并拆分、边框底色、重复标题行与跨页控制 |
| DOC-08 | 图片与对象 | 支持插入、替换、裁剪、缩放、锚点和文字环绕;保留对象顺序 |
| DOC-09 | 引用结构 | 支持目录、脚注、尾注、书签、超链接、题注和交叉引用 |
| DOC-10 | 公式 | 使用内核已有公式能力插入与修改;导出后保留可编辑表示或说明格式限制 |
| DOC-11 | 审阅 | 支持批注、回复、修订记录、接受或拒绝修改;批注可定位到内容 |
| DOC-12 | 查找与检查 | 支持查找替换、大小写选项、字数统计;提供随包词典支持的离线拼写检查 |
| DOC-13 | 文档比较 | 对两个本地版本生成差异或审阅结果;保留两个源文件,不自动合并 |
| DOC-14 | 邮件合并文档 | 从 CSV 选择字段并预览,生成独立文档或 PDF;不自动发送邮件 |
| DOC-15 | 打印与导出 | 支持打印预览、页面范围、DOCX/ODT 保存与 PDF 导出;批注和修订输出方式可选择 |
| DOC-16 | 长文档导航 | 大纲、页码、搜索和书签能跳转;跳转后可回到原位置 |
### 8.3 关键流程与限制
打开合同后先检查密码、字体和可识别的兼容风险。用户修改条款、添加批注后保存。另用目标 Office 应用打开保存结果,检查内容、编号、表格、修订和页码。截图相近但修订丢失,仍判失败。
不执行 VBA、嵌入式程序或 ActiveX。对不能保证往返保存的宏文件、受保护对象和专有嵌入内容,打开时进入只读或风险状态。转换为 DOCX/ODT 时另存新文件,不覆盖原件。
## 9. ElyTable
### 9.1 定位与布局
用于清单、预算、数据整理、公式计算、图表和汇总分析。上方提供名称框与公式栏,中间是内核表格视图,下方是工作表标签和选择统计。属性面板按单元格、图表或数据区域变化。
新建默认格式为 XLSX。公式、单元格值、工作表和依赖关系只由 LOK 维护。组件库中的表格逻辑不作为第二个计算内核。
### 9.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| TABLE-01 | 工作簿 | 新建、保存、重命名、复制、移动、隐藏和删除工作表;删除前说明引用影响 |
| TABLE-02 | 区域编辑 | 支持单元格和区域选择、插入删除、行列尺寸、合并、冻结、隐藏和填充柄 |
| TABLE-03 | 公式输入 | 支持公式栏、引用选取、函数提示、错误定位及相对、绝对、混合和跨表引用 |
| TABLE-04 | 公式计算 | 覆盖算术、逻辑、条件统计、文本、日期、查找和动态数组;使用 LOK 计算结果 |
| TABLE-05 | 计算控制 | 支持自动计算、手动重算、循环引用提示;加载和保存后保持所选计算规则 |
| TABLE-06 | 名称与引用 | 支持命名区域、范围更新、引用跳转和引用错误提示 |
| TABLE-07 | 格式 | 支持数字、货币、百分比、日期、自定义格式、对齐、边框、填色和自动换行 |
| TABLE-08 | 数据整理 | 支持排序、筛选、去重、查找替换、分列和粘贴为值或公式 |
| TABLE-09 | 规则 | 支持数据验证、下拉选择、输入提示、无效数据提示和条件格式 |
| TABLE-10 | 汇总分析 | 支持数据透视表的创建、字段调整、聚合方式和刷新;显示源数据范围 |
| TABLE-11 | 图表 | 支持柱、线、条、散点和饼图;编辑数据范围、轴、图例和标题 |
| TABLE-12 | CSV/TSV | 导入前选择编码、分隔符、引号与列类型;保留账号、编号和前导零 |
| TABLE-13 | 导出 | 保存 XLSX/ODS;CSV/TSV 导出明确只含哪个工作表,并提示公式和样式损失 |
| TABLE-14 | 打印 | 支持打印区域、重复标题、分页、方向、缩放和 PDF 导出 |
| TABLE-15 | 保护 | 支持内核已实现的工作表保护、锁定单元格和编辑许可;不绕过密码 |
| TABLE-16 | 大数据浏览 | 只渲染可视区域;滚动不为所有空单元格创建 UI 对象 |
公式验收至少包含 SUM、AVERAGE、COUNT、SUMIF/SUMIFS、COUNTIF/COUNTIFS、IF/IFS、IFERROR、ROUND、文本与日期函数、INDEX/MATCH、VLOOKUP/XLOOKUP、FILTER、SORT 和 UNIQUE。该清单是产品目标;必须确认所锁定 LOK 版本及文件过滤器能完成计算和往返保存,不能因函数名可输入就认定已支持。
### 9.3 数据正确性
日期系统、时区相关输入、区域小数点和分隔符应有明确处理规则。数值和显示格式分开比较。导入 CSV 不自动把长编号转换成科学计数法,也不把所有文本都作为公式执行。
不支持的函数显示错误或兼容提示,并保留原公式和可用的缓存值;不能用 0、空值或旧缓存冒充重算结果。外部数据链接离线时保留可识别的旧值,显示未刷新状态。
不纳入 Power Query、Power Pivot、Excel VBA、外部数据库连接管理和专有插件。包含这些内容的文件按兼容规则处理,不承诺原样编辑往返。
## 10. ElyShow
### 10.1 定位与布局
用于创建、编辑和播放演示文稿。左侧显示幻灯片缩略图,中间显示编辑画布,右侧显示当前对象或页面属性,下方提供备注。新建默认格式为 PPTX。
### 10.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| SHOW-01 | 幻灯片管理 | 支持新建、复制、删除、重排、隐藏、章节管理和页面尺寸设置 |
| SHOW-02 | 模板与母版 | 支持主题、母版、布局与占位符;修改母版后更新其引用页面 |
| SHOW-03 | 文字 | 支持文本框、段落、列表、字体、颜色、尺寸和自动适配规则 |
| SHOW-04 | 图形 | 支持基本图形、连接线、填充、描边、旋转、透明度和阴影 |
| SHOW-05 | 图片 | 支持本地插入、替换、裁剪、缩放和图层顺序 |
| SHOW-06 | 对象操作 | 支持多选、对齐、分布、组合、锁定、吸附、参考线和对象列表 |
| SHOW-07 | 表格与图表 | 插入并修改表格和常用图表;数据与对象在保存后仍可编辑 |
| SHOW-08 | 备注与审阅 | 支持演讲备注、批注和定位;备注不出现在观众屏上 |
| SHOW-09 | 动画与切换 | 支持淡入淡出等基础动画、顺序、持续时间和页面切换;提供预览 |
| SHOW-10 | 媒体 | 插入并播放本地音视频;丢失链接和不支持的编码有提示 |
| SHOW-11 | 放映 | 支持全屏、从当前页播放、跳页、黑屏、激光指示和退出 |
| SHOW-12 | 多屏 | 支持选择观众屏和演讲者视图,显示当前页、下一页、备注与计时 |
| SHOW-13 | 输出 | 支持 PPTX/ODP、PDF、页面图片和讲义打印;导出范围可选择 |
| SHOW-14 | 演示准备 | 检查缺失字体、缺失媒体、外部链接和不支持对象,并给出文件位置 |
### 10.3 放映与兼容
放映使用内核能力及必要的平台媒体支持,不把页面截图轮播当成完整放映。缩略图与动画预览不阻塞编辑。
基础动画、媒体播放和演讲者视图必须分别验证 LOK 接口与平台实现。上游桌面应用具备某功能,不表示 LOK 接入已经完成。未完成这些功能不得以隐藏按钮方式通过首版验收。
首版媒体验收覆盖本地 MP4 的 H.264/AAC 组合与常用音频文件。发行前检查解码依赖及许可;媒体不能播放时保留文件和对象,显示原因。嵌入媒体随文档保存;外部链接媒体按链接处理,不伪装成已嵌入。
不承诺任意 PowerPoint 高级特效和专有对象的等效行为。不能编辑的对象采用兼容提示,不能静默栅格化后继续宣称可编辑。
## 11. ElyPDF
### 11.1 定位与内核
提供 PDF 阅读、对象编辑、批注、表单、页面组织、OCR、内容删除和签名相关操作。MuPDF 通过 C 接口接入 Rust;不为使用 MuPDF 强制引入 Python。MuPDF 是底层库,编辑器交互由 Ely 实现。[S6][S7]
左侧提供缩略图、书签和附件,中央显示页面,右侧显示编辑、批注或表单属性。所有模式使用同一套选择、撤销、保存和状态规则。
### 11.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| PDF-01 | 阅读 | 支持连续、单页、双页、缩放、旋转显示、书签、文本选择与全文搜索 |
| PDF-02 | 页面组织 | 支持插入、删除、重排、旋转、裁剪、拆分、合并和提取;修改后更新页码与引用 |
| PDF-03 | 文字编辑 | 支持编辑可识别的现有文字对象、插入文字、调整字体字号颜色和文本框;不通过白块遮盖冒充编辑 |
| PDF-04 | 图像与对象 | 支持插入、替换、移动、缩放和删除图片及受支持对象 |
| PDF-05 | 批注 | 支持高亮、下划线、删除线、便签、形状、手绘、印章与批注列表 |
| PDF-06 | 表单 | 创建和编辑基础 AcroForm 字段;支持文本、复选、单选、选择框和填写后保存 |
| PDF-07 | 文档结构 | 支持书签、链接、元数据和附件的查看与管理 |
| PDF-08 | OCR | 对扫描页进行本地中英文识别,生成可搜索文字层;可选页面范围、语言和覆盖策略 |
| PDF-09 | 内容删除 | 标记区域或文本,预览后执行真正删除;输出文件不保留被删除对象与旧修订内容 |
| PDF-10 | 文档清理 | 提供清除元数据、批注、附件和隐藏内容的明确选项;显示处理范围 |
| PDF-11 | 密码 | 在用户提供授权密码后打开;支持输出加密文件和权限设置;不破解未知密码 |
| PDF-12 | 签名 | 插入可视签名;使用用户提供的本地证书创建数字签名;显示已签文件的验证状态 |
| PDF-13 | 输出 | 保存 PDF、导出页面图片与文本、打印;可检查压缩和图像降采样设置 |
| PDF-14 | 文件恢复 | 支持修复后打开的副本和错误报告,不覆盖损坏原件 |
OCR 复用 Tesseract API 和现成语言数据。识别结果是新增的文字层,不把扫描图片自动宣称为可无损重排的段落。[S21]
### 11.3 文字编辑边界
PDF 编辑以现有页面与对象为单位。支持文本框内修改和必要的局部重排,不承诺 Word 式跨页重排。无法识别为文字的路径或扫描内容,不显示“直接编辑原文字”。
字体缺失、字形不全或原字体不能用于新增内容时,提示用户选择可用字体。替换可能改变布局时提供预览。不得先删除原内容,再因新文字绘制失败留下空白。
### 11.4 内容删除与签名规则
真正删除包括应用删除操作、重新写出文件及清理不再引用的对象。敏感内容删除不得使用保留原始修订的增量保存方式。验收同时检查文本提取、对象内容和图片区域,不能只看页面上的黑色矩形。
内容删除默认输出新文件。明确告知用户:原件、旧备份、外部同步历史和已经发送的副本仍可能包含原内容。清理 Ely 自己生成的临时副本时说明范围;不声称完成了存储介质级安全擦除。
可视签名不等于数字签名。离线验证须分别显示签名完整性、本地证书信任情况以及吊销状态是否核验。无法联网检查时显示“未核验吊销状态”,不显示笼统的“全部有效”。
修改已签文件前说明对签名的影响,默认另存未签修改副本。创建新的数字签名后不得再用普通自动保存改写文件。
XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Office 的无损逆向转换不在范围内。
## 12. ElyMail
### 12.1 定位与后端
提供多账号邮件客户端,不建设邮件托管服务。MailCore 2 负责 IMAP、POP、SMTP 和邮件解析等能力。Ely 负责账号流程、本地存储、搜索、同步队列和界面。MailCore 2 官方列出 C++ API 与三端支持,但发行前仍须验证实际构建和依赖。[S10]
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区使用本地内容宿主,不能把正文 HTML 解析当成完整页面渲染。
### 12.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| MAIL-01 | 账号 | 支持多个 IMAP/SMTP 账号、POP3 账号、服务器配置、TLS 与连通性检查 |
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程;支持服务商允许的应用密码或自定义认证配置 |
| MAIL-03 | 邮箱视图 | 支持统一收件箱、账号独立视图、文件夹、未读、标记和附件筛选 |
| MAIL-04 | 阅读 | 支持纯文本、HTML、内嵌图片、附件、原始邮件头与会话线程 |
| MAIL-05 | 写信 | 支持收件人、抄送、密送、回复、回复全部、转发、签名、附件和纯文本/富文本撰写 |
| MAIL-06 | 草稿 | 编辑中保存本地草稿;可选择同步到服务端草稿箱;恢复不依赖网络 |
| MAIL-07 | 发送 | 提供持久化发件队列、重试、取消待发送、附件大小提示与发送结果 |
| MAIL-08 | 组织 | 支持已读未读、标记、移动、归档、删除与恢复;映射到服务端实际能力 |
| MAIL-09 | 搜索 | 搜索本地已缓存的发件人、收件人、主题、正文、日期和附件文件名 |
| MAIL-10 | 附件 | 支持下载、离线保留、另存、预览和用 Ely 应用打开;显示缓存状态 |
| MAIL-11 | 规则 | 支持按发件人、收件人、主题设置本地过滤规则,执行移动、标记或归档 |
| MAIL-12 | 联系人 | 维护本地地址补全和联系人,支持 vCard 导入导出,不建设完整通讯录服务 |
| MAIL-13 | 迁移 | 支持 EML 查看与导出、MBOX 导入导出;导入数据不直接写入远程邮箱 |
| MAIL-14 | 同步控制 | 支持手动刷新、运行期间定时同步、暂停和按文件夹离线下载 |
| MAIL-15 | 通知 | 支持新邮件通知与静音;锁屏通知默认不显示正文 |
Gmail 和 Microsoft 的 IMAP/SMTP OAuth 有各自的应用注册、权限与服务端条件。正式版内置登录必须完成适用的注册及审核。不得把用户自行申请开发者凭据作为普通账号接入的默认步骤,也不得承诺绕过组织禁用 IMAP/SMTP 的策略。[S11][S12]
### 12.3 缓存与同步
首次连接默认下载邮件头及最近 90 天的正文。附件按需下载,用户可为文件夹启用完整离线保留。界面显示最后同步时间、下载范围、尚未缓存的内容和失败原因。搜索结果注明只覆盖本地缓存。
IMAP 消息身份包含账号、邮箱、UIDVALIDITY 与 UID,不能只用主题或 Message-ID 作为同步主键。线程结合邮件引用关系组织,不把同主题的所有邮件强行合成一组。
离线移动、删除和标记先进入本地待同步队列。服务端状态已变化时重新核对,不无限重试已不存在的消息。POP3 的“保留服务端副本”默认开启,不提供伪造的 IMAP 文件夹同步能力。
附件编辑先创建工作副本。修改附件不覆盖原始 MIME 邮件;用户需要发送修改结果时,将工作副本添加到新草稿。
### 12.4 发送状态
发件状态必须区分:草稿、排队、正在发送、服务器已接受、失败、结果未知和已取消。
SMTP 返回成功后才标记服务器已接受。这不等于收件人已收到或已阅读。服务器已接受但保存“已发送”副本失败时,只重试保存副本,不重发邮件。
传输结束后连接中断、无法确认服务器是否接受时,进入结果未知。保留 Message-ID 和发送记录,允许检查后处理,不自动重发造成重复邮件。
用户在离线状态点击发送,即授权该队列项在恢复连接后尝试发送;界面当时必须说明,并允许取消。应用重启后,对遗留的结果未知项不得自动再次发送。关闭应用时说明未发送队列是否仍会运行;没有后台进程时不声称会继续发送。
HTML 邮件默认阻止远程图片和跟踪资源,禁用脚本。用户可以对当前邮件或发件人授权加载。邮件内容无法调用编辑器的本地文件桥接接口。
完整日历、Exchange 本地部署专有协议、企业归档、S/MIME 与 PGP 密钥管理不在范围内。
## 13. ElyTree
### 13.1 定位与后端
提供独立 Git 桌面客户端。使用 Git CLI 执行仓库操作,使用现有 Git、差异和树形组件呈现结果。不重写 Git 对象存储、索引和合并算法。
通过参数数组启动 Git,不拼接 shell 命令字符串。状态读取使用适合程序处理的格式,例如 `git status --porcelain=v2 -z`,不解析终端彩色输出。[S13]
### 13.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| TREE-01 | 仓库 | 支持初始化、打开、克隆、最近仓库和多仓库切换;显示实际路径 |
| TREE-02 | 工作区 | 区分未跟踪、未暂存、已暂存、冲突和忽略文件;响应外部 Git 操作 |
| TREE-03 | 差异 | 支持行内/并排差异、空白选项、文件重命名和图片差异预览 |
| TREE-04 | 暂存 | 支持按文件、差异块及可验证的行选择暂存和取消暂存;提交前显示实际索引内容 |
| TREE-05 | 提交 | 支持提交、修改最近提交、提交信息、身份与已有 Git 签名设置 |
| TREE-06 | 历史 | 支持提交图、提交详情、文件历史、Blame 和提交搜索 |
| TREE-07 | 分支与标签 | 支持创建、切换、重命名、删除分支,以及创建和删除标签 |
| TREE-08 | 合并与变基 | 支持 merge、rebase、cherry-pick、revert,以及继续、中止和错误恢复 |
| TREE-09 | 冲突 | 显示 base、当前侧和另一侧,提供块级选择与手动编辑;保存后明确标记已解决 |
| TREE-10 | 暂存工作 | 支持 stash 创建、查看、应用、弹出和删除;应用失败保留原 stash |
| TREE-11 | 远程 | 支持远程地址管理、fetch、pull、push、上游分支和进度取消 |
| TREE-12 | 仓库结构 | 支持 worktree、submodule 的状态与基础管理;显示 LFS 指针及对象可用状态 |
| TREE-13 | 恢复与清理 | 提供 reflog 查看、恢复引用、丢弃改动和清理未跟踪文件;危险操作先预览 |
| TREE-14 | 外部工具 | 支持打开终端、文件管理器和指定编辑器;可在 ElyMark 打开 Markdown |
### 13.3 操作规则
同一仓库的写操作串行执行。外部 Git 进程占用锁时等待或提示,不擅自删除锁文件。用户从终端切换分支后,应用必须重新读取工作区、索引和 HEAD。
强制推送默认采用带租约保护的方式,显示远端、分支及待覆盖影响。普通按钮不得隐含硬重置。丢弃工作区文件、清理未跟踪文件和删除分支的确认不可复用为以后所有危险操作的永久授权。
合并与变基中的两侧名称按当前操作解释,不能固定把“ours”标为用户期望保留的一侧。冲突编辑结果与暂存动作分开。取消差异查看不改变仓库。
认证复用 Git credential helper、系统 SSH 和用户已有签名工具。密钥不写入应用数据库。终端交互、凭据提示和 hook 失败必须可见,不隐藏输出后只给出“操作失败”。
Git 与必要的 LFS 运行程序随 ElyTree 发行包提供,允许用户切换到已验证的系统版本。显示当前路径和版本。代理、凭据和签名遵守所选 Git 环境,不维护一套不可见的替代配置。
DOCX、XLSX、PPTX 和 PDF 默认按二进制文件展示变更。可提供预览,但不承诺文本级合并。ElyTree 不强制其他应用把文件放入 Git 仓库。
Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不在范围内。
## 14. ElyMark
### 14.1 定位与编辑框架
提供以本地 Markdown 文件为中心的编辑器。支持可视化编辑、源码编辑和只读预览。文件可由其他编辑器、Git 或 Agent 修改,不要求导入专有知识库。
默认使用 Milkdown 承担可视化编辑,CodeMirror 6 承担源码编辑。两者接入同一个文件会话,只允许当前模式提交修改,不同时维护两份独立可写文档。Milkdown 的 Markdown 与编辑模型转换需要单独处理源码保留,不能把序列化输出默认视为无损原文。[S14][S15][S16]
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则,不复制其整个应用窗口,不把 Electron 作为 Ely 的桌面外壳。当前 ColaMD 仍包含 Milkdown 与 CodeMirror 相关依赖,但具体实现必须按选定提交核对,不凭包名推断编辑行为。[S17][S18]
### 14.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| MARK-01 | 本地文件 | 新建、打开、保存、重命名和另存;支持直接打开文件或文件夹 |
| MARK-02 | 编辑模式 | 可视化、源码和只读预览可切换;切换本身不修改文件内容 |
| MARK-03 | Markdown | 支持 CommonMark 与 GFM 常用语法:标题、列表、任务、引用、链接、图片、表格和代码块 |
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;资源随包提供 |
| MARK-05 | 导航 | 提供文件树、文档大纲、多标签、最近文件和按标题跳转 |
| MARK-06 | 编辑工具 | 支持查找替换、源码正则搜索、缩进、列表续写、表格行列操作和代码高亮 |
| MARK-07 | 附件 | 支持拖放和粘贴图片,默认复制到文档旁的资源目录并写入相对路径 |
| MARK-08 | 外部修改 | 监视文件内容、删除、重命名和原子替换;未保存内容参与冲突处理 |
| MARK-09 | 书写视图 | 支持专注模式、打字机滚动、正文宽度和字体设置;不改变 Markdown 语义 |
| MARK-10 | 搜索 | 提供当前文件与用户打开文件夹内的搜索;不默认扫描整盘 |
| MARK-11 | 输出 | 导出自包含或带资源目录的 HTML、PDF 和纯文本;支持打印及富文本复制 |
| MARK-12 | 会话恢复 | 恢复打开标签、未保存内容、光标、滚动和视图设置 |
| MARK-13 | Git 配合 | 在仓库内显示文件是否有改动,并可调用 ElyTree 查看;不内置第二套 Git 客户端 |
| MARK-14 | 大文件 | 大文件采用延迟渲染或源码视图,说明限制,不截断保存内容 |
数学与 Mermaid 默认仅渲染文档中已保存的源代码,不执行任意本地命令。语法错误显示原文与错误位置,不清空代码块。
### 14.3 原文与保存规则
未发生编辑时,打开、切换视图和保存不得改写原文件。保留 BOM 状态、换行风格、front matter 原文、代码块内容和不认识的扩展语法。
可视化编辑允许规范化实际修改的语法块,但不得借机重写整篇文档。框架无法保留的结构应显示为原文块,或提示在源码模式编辑,不能删除后重建。
模式切换前提交当前编辑事务,保留内容历史与当前修订。撤销只作用于本文件;不能撤销另一个标签的编辑,也不能把视图切换变成一次内容重写。
换行解释采用明确规则,默认遵循所选 CommonMark/GFM 语义。提供单换行显示为换行的阅读选项时,不自动在源文件插入 `<br>` 或额外空格。
### 14.4 外部变更
文件未修改时,外部写入触发重载,并尽量保持滚动和光标。文件有未保存内容时,不自动覆盖;提供磁盘版本、本地版本与共同基线的比较。
文件被外部删除后,仍保留内存内容并提示另存。原子保存导致的重命名事件不能被误判为永久删除。网络目录和监听失效时提供手动刷新,不声称所有文件系统都支持相同监听能力。
AI 或脚本修改文件与其他外部修改使用同一套处理规则。ElyMark 不需要依赖 ElyAgent 才能响应文件变化。
知识图谱、双向链接数据库、笔记同步服务、多人协同和插件市场不在范围内。
## 15. ElyAgent
### 15.1 定位与独立性
提供独立的 Agent 配置和会话管理界面。用户选择已安装的 Agent、工作目录、模型配置和执行参数,查看输入输出、工具调用、文件改动和运行状态。
推理循环来自嵌入的 Kigi,不另写一套;不成为其他 Ely 应用的内置 AI 功能,也不自动读取邮件或办公文档。用户通过工作目录或选定文件向后端提供内容。
### 15.2 接入基线
后端以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP(用户 2026-10-05 定)。提供商登录(订阅账号或 API 密钥)、模型与端点配置沿用 Kigi 的实现,不另写一套。不解析终端装饰文本来伪造结构化事件。
会话恢复只在 Kigi 的会话存储支持并通过验证时启用。不能把本地历史回放当成后端会话已经恢复。
### 15.3 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| AGENT-01 | 工具管理 | 检测、添加和选择本地 Agent 路径,显示版本、启动参数与可用状态 |
| AGENT-02 | 配置管理 | 按后端支持配置提供商、端点、模型及参数;密钥引用系统凭据库 |
| AGENT-03 | 配置保护 | 修改已有工具配置前备份;只修改明确字段,保留未知字段和用户注释 |
| AGENT-04 | 会话 | 按工作目录分组,支持新建、命名、搜索、归档和删除本地记录 |
| AGENT-05 | 输入输出 | 支持流式输出、停止生成、复制、日志展开和发送失败重试 |
| AGENT-06 | 并行运行 | 支持多个本机会话并行;显示各自目录、后端、模型和状态 |
| AGENT-07 | 工具与授权 | 展示后端提供的工具调用和授权请求;用户决定允许或拒绝 |
| AGENT-08 | 文件与产物 | 展示后端声明的改动、差异和产物;可打开文件或交给 ElyTree 查看 |
| AGENT-09 | 会话恢复 | 后端支持时恢复会话;否则只能查看历史或新建,不提供虚假的恢复按钮 |
| AGENT-10 | 执行控制 | 支持取消请求、停止进程和清理已退出会话;强制终止前说明影响 |
| AGENT-11 | 历史 | 本地保存可用的消息、工具事件、时间、后端会话标识和错误;可导出文本或 JSON |
| AGENT-12 | 用量 | 仅显示后端返回的 token、费用或上下文信息;缺失时显示未知 |
| AGENT-13 | 本地模型 | 允许连接后端本身支持的本地兼容端点,显示连通性;不内置模型下载与推理引擎 |
| AGENT-14 | 托盘 | 提供菜单栏或托盘入口与运行会话数量;可关闭,不依赖托盘完成操作 |
### 15.4 会话状态与生命周期
会话状态包括未启动、启动中、空闲、运行中、等待输入、等待授权、已完成、已取消、失败和连接中断。只有收到后端证据才能进入相应状态。未提供进度百分比时不估造百分比。
界面关闭窗口时,如有运行会话,提示继续在应用后台运行或停止后退出。继续运行指 ElyAgent 进程仍在,不额外安装系统级守护服务。系统无托盘时仍需保留可重新打开的应用入口。
明确选择退出应用时,说明哪些任务会终止并等待用户决定。崩溃或系统重启后只展示持久化历史及可验证的恢复选项,不承诺进程继续运行,也不自动重放可能改写文件的请求。
同一工作目录启动多个可写会话时提示可能发生冲突,提供使用独立目录或 worktree 的入口,但不强制建设自动工作树编排系统。
切换模型或提供商只按后端支持的机制执行。不宣称所有历史会话可以无损迁移到不同后端。不内置跨协议转换网关、账户轮换或额度规避功能。
独立 Agent 的安装、授权和模型供应商条件不应影响其他七个应用的本地功能。
## 16. 应用之间的连接
应用间只提供明确、可取消的文件操作,不建设跨应用事务系统。
| 入口 | 行为 | 边界 |
| --- | --- | --- |
| 邮件附件用 Ely 打开 | 将附件复制为工作副本,再交给对应应用 | 不修改原始邮件 |
| 文档导出后查看 | 在 ElyPDF 或系统默认 PDF 应用打开输出文件 | 不把导出文件当成原编辑会话 |
| Markdown 查看版本 | 向 ElyTree 传递仓库路径和文件路径 | 不自动提交、不自动创建仓库 |
| Agent 查看产物 | 按用户选择调用已安装的 Ely 应用 | 不静默打开敏感目录或执行文件 |
| 文件作为邮件附件 | 创建草稿并附加指定文件 | 不自动发送 |
| 应用切换 | 显示已安装应用并启动 | 不要求先打开统一启动器 |
目标应用未安装时,提供系统默认应用或安装说明,不阻塞原工作。应用间不读取彼此的内部数据库。
## 17. 工程结构与接入边界
### 17.1 项目组织
使用一个 Rust workspace。各应用独立编译,共享 UI、平台适配和文件处理代码。内核依赖按应用引入,不因 workspace 共存而全部链接。
```text
ely-office/
apps/
ely-doc/
ely-table/
ely-show/
ely-pdf/
ely-mail/
ely-tree/
ely-mark/
ely-agent/
crates/
ely-ui/ # 组件库的应用级组合与共用交互
ely-platform/ # 文件对话框、打印、凭据、系统集成
ely-files/ # 文件会话、保存、恢复、监听
ely-lok/ # LOK 接入;仅 Doc/Table/Show 使用
ely-pdf-engine/ # MuPDF 与 OCR 接入
ely-mail-engine/ # 邮件协议、同步和本地存储
ely-web-content/ # 本地 HTML 与 Markdown 内容宿主
assets/
tests/
packaging/
```
Ely GPUI Components 保持独立仓库依赖。ElyTree 的 Git 调用与 ElyAgent 的适配器先放在各自应用内;没有重复需求时不抽出通用框架。
### 17.2 进程与状态
GPUI 主进程负责界面和用户输入。LOK 和 MuPDF 放在本地工作进程中,避免解析或原生库故障直接终止整个界面。默认按应用实例启动所需工作进程,不预建全套进程池。
工作进程使用本地管道传递控制消息和结果。每条请求包含会话标识、请求标识及所需的修订信息。只在进程边界使用通信协议;进程内使用普通 Rust 类型和函数,不把所有组件都包装成消息服务。
文档会话、编辑状态和内核对象的责任必须明确。内核持有文档的可写状态,UI 保存可展示的状态,不双向同步两份完整文档模型。调用顺序和线程规则按内核要求执行。
C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust 调用栈传播未处理的原生错误。原生库版本和绑定一起锁定。
邮件同步、Git 命令、OCR、缩略图与索引构建在后台任务中执行,不阻塞 UI 事件循环。后台指应用运行期间的本地任务,不表示默认安装常驻系统服务。
### 17.3 本地 Web 内容宿主
Mail 的 HTML 正文和富文本撰写、Mark 的编辑与预览需要成熟 Web 内容渲染。本 PRD 的默认实现采用 CEF 离屏内容区域,只在这两个应用引入。CEF 提供离屏画面和输入事件接口,适合接入自绘界面,但 GPUI 的实际接入仍须三端验证。[S22]
CEF 只处理内容区域,不绘制应用标题栏、菜单、文件树和设置页面。不引入 Electron 应用外壳,不运行面向浏览器的本地文档服务器。CEF 必需的辅助进程随应用发行。
Ely 主题 token 同步到内容区样式。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示,不出现框架默认主题。
邮件阅读环境禁用脚本及文件桥接。Markdown 编辑环境只运行随包代码,通过受限桥接访问当前用户打开的文件与资源。外部文档内容不能调用任意本地命令。
不同时维护 CEF、系统 WebView 和全原生 Markdown 三种路线。若 CEF 原型不能满足三端要求,先修订该接入决策并更新 PRD,不能在应用内部临时混用多套未验收宿主。
### 17.4 不建设的技术设施
不引入 Kubernetes、Docker 运行依赖、远程数据库、消息中间件、事件溯源系统、通用插件市场、统一知识图谱、分布式搜索或独立账号中心。
应用间不做 RPC 服务发现。Agent 不以独立后台服务器为前提。Git 不同时接入多个 Git 内核。Markdown 不叠加第三个编辑内核。新抽象必须服务于已有的重复需求。
## 18. 数据、配置与隐私
### 18.1 存储边界
| 数据 | 存储位置与规则 |
| --- | --- |
| 办公文件、PDF、Markdown | 用户指定目录;不强制复制到应用资料库 |
| Git 数据 | 仓库自身的 Git 目录及工作区;ElyTree 只缓存视图状态 |
| 邮件与附件 | ElyMail 独立 SQLite 索引和本地内容目录;可导出和设置容量 |
| Agent 会话 | ElyAgent 独立本地记录;区分应用记录与后端自身记录 |
| 通用偏好 | 版本化的本地配置文件;仅保存主题、语言及共用交互设置 |
| 应用偏好 | 各应用独立保存窗口、最近文件、编辑和业务设置 |
| 恢复副本 | 各应用恢复目录;受时间和容量限制 |
| 密钥与令牌 | 系统凭据库;不写入普通配置、诊断日志或同步索引 |
数据库不作为通用文档格式。全文索引是可重建数据,索引损坏不能导致邮件原文、文档或会话原始记录丢失。
配置修改采用原子写入与必要的文件锁。多个应用同时修改共用偏好时合并已知字段,不互相覆盖其他字段。不为此启动常驻配置服务。
### 18.2 凭据与私密内容
Windows 使用系统凭据能力,macOS 使用 Keychain,Linux 使用可用的 Secret Service。系统凭据库不可用时,允许凭据只保留到本次退出,不能静默退回明文保存。
加密文档的密码只在需要期间保留。其恢复副本不得以明文落盘;使用成熟加密库和系统密钥保护。用户不能解锁恢复密钥时,提示无法恢复,不导出不受保护副本。
第三方模型、邮箱和 Git 服务的连接由用户配置或授权。文档编辑不向这些服务发送内容。打开文件不会自动产生 AI 请求。
诊断日志默认不写正文、附件、密钥和提示词。Agent 的本地会话历史是用户数据,不属于可自动上传的诊断日志。导出诊断包前显示内容范围,并允许移除路径和个人信息。
不内置行为广告和自动上传分析。崩溃报告由用户触发提交。更新检查与远程内容加载遵守离线及隐私设置。
### 18.3 数据管理
设置中提供缓存容量、清理、恢复副本管理、邮件导出和 Agent 历史导出。删除账号先说明会删除哪些本地缓存、草稿和凭据,不影响未授权删除的服务端数据。
数据库升级前创建备份。升级失败保留旧数据,并阻止用不兼容结构继续写入。应用降级不尝试猜测转换;允许恢复备份或只读导出。
## 19. 文件兼容与输出
### 19.1 格式范围
| 应用 | 正式编辑与保存范围 | 导入或受限范围 | 输出 |
| --- | --- | --- | --- |
| Doc | DOCX、ODT | DOC、RTF、TXT,以及可识别的模板;按兼容提示转换 | DOCX、ODT、PDF、文本 |
| Table | XLSX、ODS | XLS;CSV/TSV 按导入设置转换;宏文件只读或另存 | XLSX、ODS、CSV、TSV、PDF |
| Show | PPTX、ODP | PPT、模板及含专有对象的文件按兼容提示处理 | PPTX、ODP、PDF、PNG/JPEG |
| PDF | 本文定义的 PDF 对象、批注、表单和页面功能 | XFA、嵌入程序或无法编辑的对象只读或提示 | PDF、页面图片、文本 |
| Mail | 本地消息、草稿与服务端协议操作 | EML、MBOX、vCard | EML、MBOX、附件、vCard |
| Tree | 标准本地 Git 仓库 | 浅克隆、LFS、submodule 等显示其限制 | Git 原生提交与引用 |
| Mark | MD、Markdown 文本与本地资源 | 未支持语法保留原文;编码转换需确认 | Markdown、HTML、PDF、文本 |
| Agent | 已验证后端的配置与会话记录 | 外部会话按适配器能力读取,不作通用格式承诺 | 文本、JSON、后端支持的格式 |
支持文件格式不等于支持该格式所有功能。发行说明必须提供版本化兼容清单,注明已验证的内容、限制和保存行为。
### 19.2 往返验证
每个办公测试文件依次执行:在 Ely 打开、编辑指定内容、保存、关闭、再次打开,再由独立目标应用打开。检查结构、内容、公式、修订、批注、对象可编辑性和输出画面。
不只与同一个内核比较。DOCX/XLSX/PPTX 应包含与 Microsoft Office 的交叉检查;测试机和目标 Office 版本写入报告。用于测试的第三方软件由团队合法取得,不要求终端用户安装。
字体一致的环境与字体缺失环境分别测试。字体缺失时出现分页差异不应被隐瞒,替换规则和缺失字体列表必须可见。产品不捆绑无再分发许可的微软字体。
数值计算使用独立预期结果。精确整数、日期、错误值和文本严格比较;浮点数按测试用例声明的容差比较,不设一个掩盖所有误差的全局容差。
已知高风险文件默认不自动覆盖。无法完整识别所有未知对象时,不宣称“已经检测全部兼容风险”。首次修改此类文件应提示另存副本。
### 19.3 打印与 PDF 输出
打印预览与实际输出使用同一文档修订、纸张、页边距和缩放设置。显示页数和范围。打印取消不改变文档内容。
Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMark 的 PDF 输出由本地渲染与打印能力完成,不要求安装 LOK。ElyPDF 用于后续编辑和查看,而不是全套应用的强制导出服务。
## 20. 质量与性能要求
### 20.1 验收环境
基准使用 16 GB 内存、NVMe 存储、至少 6 个 CPU 核心或同档 Apple Silicon,以及满足所选图形后端要求的设备。三端记录具体 CPU、GPU、驱动、系统、字体、编译版本和样本哈希。
以下指标按发行构建、每项 30 次测试的 P95 统计。首次安装初始化与日常冷启动分开记录。网络、远程模型速度及打印机出纸时间不计入本地响应指标。指标是项目目标,调整需要在发布前更新文档和理由。
| 编号 | 项目 | 目标与样本 |
| --- | --- | --- |
| PERF-01 | 启动 | 无文件冷启动至窗口可操作不超过 3 秒;不预加载未使用内核 |
| PERF-02 | 交互 | 普通点击与按键反馈不超过 100 毫秒;长任务不连续阻塞主线程 100 毫秒以上 |
| PERF-03 | 文档打开 | 100 页、20 张图片、10 MB 内 DOCX,5 秒内显示首屏并接受操作 |
| PERF-04 | 表格打开 | 10 万非空单元格、1 万个简单公式的工作簿,5 秒内接受操作;重算另记录 |
| PERF-05 | PDF 浏览 | 本地 1000 页、200 MB 内 PDF,3 秒内显示第一页;不先渲染全部页面 |
| PERF-06 | 本地搜索 | 已完成索引的 10 万封邮件或 10 万提交仓库,常见查询首屏结果不超过 1 秒 |
| PERF-07 | Markdown | 1 MB 文档 2 秒内进入可编辑模式;超大文件允许进入明确的源码模式 |
| PERF-08 | 资源释放 | 连续打开关闭 20 次后,活动文档对象与句柄回到基线,内存无持续单向增长 |
| PERF-09 | 多会话 | 4 个 Agent 会话同时流式输出时,其他会话选择和日志滚动仍满足交互目标 |
不提前宣传固定内存占用或比其他 Office 更快。发布报告列出单应用、内核子进程及内容宿主的总内存,不能只测 GPUI 主进程。
### 20.2 稳定性
保存失败、内核崩溃、强制结束、磁盘不足、目录只读、临时文件损坏、失去网络、OAuth 失效、外部文件替换和系统休眠必须有测试。
主要应用完成连续 8 小时编辑与切换测试。测试中不得出现源文件损坏、跨文档撤销、无提示覆盖、邮件重复发送或 Git 状态伪报。
不能取消的操作应明确说明所处步骤,并允许用户等待或安全关闭界面。不得为了满足“有取消按钮”而在破坏性写入中任意杀进程。
### 20.3 无障碍与国际化
键盘能完成打开、编辑、保存、搜索和主要工具操作。焦点框不依赖鼠标悬停。内容区和 GPUI 区域间的焦点切换可预测。正文及常规界面文字的对比度目标至少为 4.5:1。
覆盖简体中文与英文输入、混合字体、标点、组合输入和非 ASCII 路径。日期、数字与纸张设置使用显式区域配置。修改界面语言不得重解释已存储的单元格数值或文档日期。
不因内核渲染成图块就把整个文档对辅助技术隐藏。文档内容、表格单元格和编辑状态的可访问性属于正式验收。
## 21. 开源与许可证
### 21.1 自有代码默认许可
本 PRD 对新增应用代码采用 AGPL-3.0-or-later 作为发行默认,便于与 MuPDF 的开源使用路线保持一致。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1]
第三方代码保持原许可证。不得把 LibreOffice、Git 或其他依赖的版权声明统一替换成 Ely 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
### 21.2 第三方发行要求
| 依赖 | 发行要求 |
| --- | --- |
| MuPDF | 按锁定版本的 AGPL 条款提供对应源码、修改、集成和构建资料;不把公开 UI 仓库当成已完成合规 [S8][S9] |
| LibreOffice/LOK | 保留 MPL 2.0 及附带依赖的相应声明;发布对受约束文件的修改与所需资料 [S5] |
| MailCore 2 | 保留其许可、版权和免责条款,并检查其传递依赖 [S24] |
| Git | 作为独立程序分发并遵守其 GPLv2 等实际适用条款;提供所发行版本的源码取得方式 [S23] |
| Markdown 与内容宿主 | 保留 Milkdown、CodeMirror、CEF 及其依赖的通知;逐项检查实际发行包 [S14][S16][S22] |
| OCR、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
MuPDF 的 AGPL 路线允许符合条件的开源集成;“项目开源”本身不足以证明整个发行包已经符合条件。是否收费与源码义务分开处理。[S8][S9]
普通用户文档不因经过应用编辑而自动成为应用源码。不要在产品协议中要求用户公开文档或邮件。许可证审查涉及应用与依赖,不把用户内容列入发布源码包。[S9]
### 21.3 发布资料
每个发行版本必须附带自有源码、锁定依赖清单、上游补丁、构建步骤、第三方通知和许可证文本。二进制下载页提供对应版本源码入口。不得只链接到会变化的上游默认分支。
公开构建说明必须足以让贡献者重建应用,不依赖未公开私有脚本。签名密钥和用户凭据不属于公开构建资料。
本节作为发布检查要求,不替代针对最终发行包的法律审查。
## 22. 测试与验收
### 22.1 测试资料
使用可再分发、自行制作或已获得测试授权的样本。真实客户资料须先取得授权并去除不应公开的信息,不放入公共仓库。
每个功能编号至少关联一个测试用例。每项用例记录输入样本、操作、预期结果、平台、内核版本和实际结果。测试未运行、失败与不适用分开记录。不能用“接口存在”替代操作验证。
UI 自动测试、内核测试和手工交互测试互相补充。三端的输入法、打印、屏幕阅读器、外接显示器和系统凭据流程需要真实环境检查。
### 22.2 跨应用验收用例
| 用例 | 覆盖范围 | 通过条件 |
| --- | --- | --- |
| QA-01 | 安装与离线启动 | 干净测试机断网安装;八个应用均可打开;没有登录、CDN 或外部文档服务器依赖 |
| QA-02 | 独立安装 | 只安装 Mark、Tree 或 Agent 时不安装或启动 LOK;只安装 Doc 时可独立导出 PDF |
| QA-03 | 统一界面 | 菜单、属性、错误、快捷键和主题符合第 5 节;没有上游产品界面泄露 |
| QA-04 | 输入与焦点 | 三端输入中文、选择文本、打开工具栏、提交组合输入和撤销,结果正确 |
| QA-05 | 多屏 | 缩放和屏幕变化后,选区、点击、浮层及演示画面不偏移 |
| QA-06 | 保存失败 | 断开目标盘、设置只读或写满磁盘,原文件保留,未保存状态和恢复内容正确 |
| QA-07 | 外部冲突 | 外部程序在本地未保存时覆盖文件,Ely 保留两份内容并要求处理 |
| QA-08 | 并发保存 | 在旧修订保存期间继续编辑,完成后仍显示新修订未保存 |
| QA-09 | 崩溃恢复 | 终止内核或应用,重新启动能定位恢复副本;不覆盖原件,不声称恢复不存在的历史 |
| QA-10 | Doc | 合同、长报告、跨页表格、目录和修订文件完成编辑、保存及独立应用往返验证 |
| QA-11 | Table | 函数、引用、日期、透视表、图表和 CSV 样本与预期一致;未知公式不显示伪结果 |
| QA-12 | Show | 母版、图表、备注、动画和本地媒体可编辑;多屏放映不暴露备注;PPTX 对象可继续编辑 |
| QA-13 | PDF 编辑 | 修改文字、图像、表单和页序后,在独立阅读器检查;失败不破坏原对象 |
| QA-14 | PDF 删除 | 应用删除后通过对象、文本和图像检查确认输出文件中无被删内容及旧修订残留 |
| QA-15 | PDF OCR 与签名 | 扫描件可本地搜索;签名显示完整性和信任边界;离线不虚报吊销核验 |
| QA-16 | Mail 离线 | 无网络可读取已缓存内容、搜索、写草稿和入队;未缓存附件不显示成已下载 |
| QA-17 | Mail 发送 | 模拟发送前失败、接受后断连和已发送副本失败,不重复发送,不误报最终状态 |
| QA-18 | Mail 同步 | 文件夹变更、UIDVALIDITY 变化、服务端删除和令牌失效后不丢本地草稿,不发生错误关联 |
| QA-19 | Git 结果 | GUI 操作后用独立 Git 命令检查索引、提交、引用和工作区,结果一致 |
| QA-20 | Git 异常 | 测试冲突、hook 失败、凭据失败、锁竞争、变基中断和取消;状态与实际仓库一致 |
| QA-21 | Mark 原文 | 无编辑保存逐字节一致;局部编辑不重写无关段落、front matter 和未知语法 |
| QA-22 | Mark 外部变更 | 测试原子替换、删除、重命名和并行编辑;不覆盖未保存内容,不截断导出 |
| QA-23 | Agent 能力 | 测试结构化和普通命令后端;不支持恢复、费用或权限事件时显示真实限制 |
| QA-24 | Agent 生命周期 | 并行会话、窗口关闭、停止、完整退出和重启后的状态与进程事实一致 |
| QA-25 | 隐私与断网 | 抓取测试机流量,未授权时无文档、正文、日志、CDN 或自动更新请求 |
| QA-26 | 升级与卸载 | 升级失败保留原版本和数据;卸载不删除用户目录;剩余应用继续启动 |
| QA-27 | 许可证 | 安装包、源码包、通知和构建资料相互对应,所打包资源均有来源与许可记录 |
| QA-28 | 无障碍与性能 | 完成第 20 节指标和键盘、辅助技术检查,报告记录全部子进程资源 |
### 22.3 不得带入正式版的问题
以下任一问题阻止正式发布:源文件损坏;静默丢失受支持内容;覆盖未保存文件;跨标签撤销;公式伪结果;PDF 删除后仍可恢复敏感内容;无法确认的邮件被自动重发;Git 危险操作未说明;Agent 会话被伪报为可恢复;三端核心编辑不能完成;发行包缺少必要许可或对应源码。
其他问题必须写入已知问题说明,并且不破坏本文定义的必需功能。不能通过把必需功能改成隐藏、只读或占位来规避发布要求。
## 23. 必须验证的工程风险
这些检查属于实现和发布流程,不构成分阶段交付。当前文档未包含三端集成程序的实测结果。
| 检查项 | 需要提交的证据 | 不通过时的处理 |
| --- | --- | --- |
| LOK 三端嵌入 | Doc/Table/Show 的编辑、保存、中文输入与窗口操作录像及日志 | 修复适配或更新设计决策;不能只交付查看器 |
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
| 本地 Web 内容 | CEF 与 GPUI 的输入、焦点、缩放、弹层和辅助技术测试 | 接入未通过不能以独立 Electron 窗口代替 |
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
| Agent 后端差异 | 每个预设后端的版本、启动、控制、日志和恢复能力表 | 按真实能力显示,禁止模拟未提供的接口 |
| 打包与许可 | 三端依赖清单、源码对应关系、资源再分发检查 | 发行前解决,不以“整个项目开源”代替检查 |
选择版本时记录源码提交、编译选项和补丁。升级 LOK、MuPDF、GPUI、CEF 或编辑框架后运行相关回归测试。不要跟随默认分支自动升级正式构建。
## 24. 交付物与完成定义
### 24.1 工程交付物
交付八个应用的三端发行包、对应源码、构建与打包脚本、依赖与许可证清单、格式兼容清单、测试样本和测试报告。帮助内容包括打开保存、恢复、离线边界、账号授权、仓库操作及 Agent 后端限制。
不为满足交付清单制造独立文档系统。构建说明、已知限制和测试报告可放在仓库现有文档目录。需求以本文件为准,代码中的功能状态不得与文档相反。
### 24.2 首个正式版完成定义
八个应用可以独立安装和运行。本文功能表均有实现与测试。三端核心流程通过。文件保存、计算、发送和 Git 操作的结果可检查。所有应用遵守同一套界面规范,用户不需要学习不同的保存和错误处理方式。
离线安装不缺资源。没有强制 Ely 账号、云服务、在线 Office 服务或跨应用 Agent 依赖。发行包与公开源码匹配,已知兼容限制写明。未验证事项不能写成已完成能力。
## 25. 资料来源
核对日期:2026-10-01。来源用于确认接口、项目结构和许可条件,不证明 Ely 已完成接入。产品功能、默认设置与性能目标由本文定义。正式实现应记录所采用版本的固定提交和许可证,而不是只引用默认分支。
[S1] Ely GPUI Components,README,组件库使用方式、主题及许可证。
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/README.md`
[S2] Ely GPUI Components,TASKS,组件分类与 host/prove 等状态定义。
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/TASKS.md`
[S3] LibreOfficeKit 官方文档,C/C++ 接口、分块渲染和输入回调。
`https://docs.libreoffice.org/libreofficekit.html`
[S4] LibreOfficeKit 官方头文件,实际接口及不稳定 API 条件。
`https://raw.githubusercontent.com/LibreOffice/core/master/include/LibreOfficeKit/LibreOfficeKit.h`
[S5] LibreOffice 官方许可证说明。
`https://www.libreoffice.org/licenses/`
[S6] MuPDF 官方 C Library 文档。
`https://mupdf.readthedocs.io/en/latest/reference/c/index.html`
[S7] MuPDF 官方能力说明。
`https://mupdf.com/`
[S8] MuPDF 官方许可证文档;本次读取页面版本为 1.28.5。
`https://mupdf.readthedocs.io/en/1.28.5/license.html`
[S9] GNU Affero General Public License 第 3 版原文。
`https://www.gnu.org/licenses/agpl-3.0.en.html`
[S10] MailCore 2 官方介绍,协议、API 与平台范围。
`https://etpan.org/mailcore2.html`
[S11] Google,Gmail IMAP/POP/SMTP OAuth 2.0 与权限要求。
`https://developers.google.com/workspace/gmail/imap/xoauth2-protocol`
[S12] Microsoft,IMAP/POP/SMTP OAuth 身份验证。
`https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth`
[S13] Git 官方文档,git-status 与 porcelain 输出格式。
`https://git-scm.com/docs/git-status`
[S14] Milkdown 官方仓库,框架结构与许可证。
`https://github.com/Milkdown/milkdown`
[S15] Milkdown 官方 Transformer 文档,编辑模型与 Markdown AST 转换。
`https://milkdown.dev/docs/api/transformer`
[S16] CodeMirror 官方开发入口与仓库迁移说明;应用依赖按实际发布包锁定。
`https://github.com/codemirror/dev`
[S17] MarkText 官方仓库,编辑模式、格式能力与许可证。
`https://github.com/marktext/marktext`
[S18] ColaMD 官方仓库及 package.json,本地文件行为、架构参考与依赖。
`https://github.com/marswaveai/ColaMD`
`https://github.com/marswaveai/ColaMD/blob/main/package.json`
[S19] Magpie 官方仓库,独立 Agent 工具定位参考。
`https://github.com/yetone/magpie`
[S20] Agent Client Protocol,Session Setup 与能力协商。
`https://agentclientprotocol.com/protocol/v1/session-setup`
[S21] Tesseract 官方 API 示例与本地 OCR 接入。
`https://tesseract-ocr.github.io/tessdoc/APIExample.html`
[S22] Chromium Embedded Framework 官方说明与离屏渲染接入。
`https://github.com/chromiumembedded/cef`
`https://chromiumembedded.github.io/cef/general_usage.html`
[S23] Git 官方 COPYING 文件。
`https://raw.githubusercontent.com/git/git/master/COPYING`
[S24] MailCore 2 官方 LICENSE 文件。
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
+280
View File
@@ -0,0 +1,280 @@
# Ely Office
单一事实来源。技术栈或产品方向变更时,同步改本文件。需求以 `PRD.md` 为准。进度以 `tasks/TASKS.md` 为准。
## 产品
八个桌面应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyMail、ElyTree、ElyMark、ElyAgent。离线优先,文件在用户目录。
## 技术栈
| 层 | 选型 |
| --- | --- |
| 界面 | Rust + GPUI + Ely-GPUI-Components |
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
| Mail | MailCore 2 + SQLite;正文用 CEF 离屏内容区 |
| Tree | Git CLI,参数数组启动 |
| Mark | Milkdown + CodeMirror 6,CEF 离屏内容区 |
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
## 目录
```text
apps/ 八个应用,各自一个二进制;ely-mark/web 是其网页源码(锁定版本,esbuild 构建)
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端)ely-web-content(CEF 离屏页面,ElyMark 与 ElyMail 共用);ely-mail-engine 随其任务建
reference/ 上游源码,只读,不属于本项目,不审查
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本;content/<应用> 是网页内容的构建产物
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层
assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns、png、ico,规则见其 README
dist/ 打包产物,不入库
docs/ 工程风险与已知限制
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
tasks/ TASKS.md 进度真相
site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木;中英两种文案在 src/text.ts);site/video 是发布视频的分镜、配乐、录屏与合成脚本,产物在 site/video/out(不入库)
```
## 组件映射
依赖 `ely-gpui-component`(pin 5d6efd4,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
| 窗口外壳 | `shell::{TitleBar, TabBar, StatusBar, Toolbar, ToolbarGroup, WindowSwitcher, ZoomControl}` `layout::{AppShell, SplitPane, Sidebar, Dock}` |
| 命令与菜单 | `menus::{MenuBar, ContextMenu, DropdownMenu}` `navigation::{CommandPalette, QuickOpen}` |
| 导航 | `navigation::{EditorTabs, Tabs, Breadcrumb, TableOfContents}` `lists::{Tree, Outline, FileTree, SelectableList}` |
| 属性与表单 | `data_display::{PropertyGrid, DescriptionList}` `forms::{Select, FontPicker, ColorPicker, NumberInput, ScrubInput, Switch, SearchInput, TextInput}` `buttons::{Button, IconButton, ToggleButton, SegmentedControl}` |
| 对话框 | `overlays::{Dialog, ConfirmDialog, AlertDialog, PromptDialog, Popover}` |
| 反馈 | `feedback::{Toaster, ToastViewport, SaveState, SavingIndicator, SyncStatus, Alert, Banner, EmptyState, ErrorView}` `shell::{OfflineIndicator, CrashReporter, UpdateBanner, UpdateDialog}` |
| 设置 | `settings::{SettingsLayout, SettingsSection, SettingsRow, ThemeSelector, KeyboardShortcutsList, StorageSettings, PrivacySettings}` |
| Doc | `documents::{PrintPreview, TemplatePicker, WordCount, PageThumbnailList}` `editor::{FindWidget}` `collab::{CommentSidebar, Thread, Comment, Peer}` `lists::{List, ListItem}` |
| Table | `tables::{PivotTable, FilterBuilder, SortBuilder}` `misc::{CsvImporter, ExportDialog}` `editor::{CompletionMenu, SignatureHelp, FindWidget}` `forms::{CheckboxGroup, ColorPalette}` |
| Mail | `mail::{MailboxList, MailList, MailThreadView, MailReader, MailComposer, SignatureEditor, ScheduleSend}` |
| Tree | `git::{ChangesList, CommitInput, CommitList, lanes, DiffViewer, ConflictResolver, BlameView, BranchList, TagList, StashList, FileHistory}` |
| Mark | `editor::{CodeEditor, FindWidget}` `documents::{MarkdownRenderer, ZenMode}` |
| Agent | `agent::{ToolCallCard, PermissionPrompt, ToolApprovalDialog, AgentStepList, FileChangeCard, MultiFileDiffReview, ArtifactPanel, CostBreakdown}` `chat::{MessageList, PromptInput, StreamingMarkdown, ConversationList}` |
| 动效 | `motion::{duration, Flip, Reorder, Transition, Stagger, ProgressBar, Spinner, Skeleton}` |
| 主题 | `theme::{Theme, Mode}`;Omarchy 色板由 `ely-ui` 映射成组件库的调色板 |
## 缺口
组件库没有的,在本项目内建:
| 缺口 | 位置 |
| --- | --- |
| LOK 图块画布与输入桥 | `crates/ely-lok` |
| 富剪贴板:gpui 剪贴板只有文本与图片,无 HTML(富文本、表格) | `ely_platform::clipboard`,用 arboard(三端 HTML、PNG) |
| gpui 无障碍只有 `aria_value` 等,无文本选区与插入点:LOK 文档的光标与选区到不了辅助技术 | `LokView` 只桥接当前段落文本(`Role::MultilineTextInput`)。上游待办:gpui 补文本选区 AX |
| 选中对象的框与手柄:LOK 不画进图块 | `LokView` 按图形选区画框与 8 个手柄;按住手柄拖动经 `setGraphicSelection` 交内核(缩放;裁剪模式下裁剪) |
| 虚拟化 PDF 页面栈(`DocumentViewer` 一次铺全部页,不合 PERF-05) | `crates/ely-pdf-engine`、`apps/ely-pdf` |
| CEF 离屏内容视图 | `crates/ely-web-content` |
| CEF 页面的无障碍:离屏渲染的页面不进系统 AX 树(CEF 另给 AX 事件,GPUI 无从挂接) | 未桥接;页面文字在辅助技术里不可见。上游待办:gpui 可挂外部 AX 子树 |
| 组件库的大纲项(`lists::Outline`)与提示(`feedback::Alert`)无无障碍角色:屏幕阅读器读不到 | 上游待办;ElyMark 的搜索失败改为结果列表里的行。`feedback::Banner` 已修(fac20ab:危险与警告为 alert,其余为 status,名为其文字) |
| `documents::ZenMode` 把正文夹进 Sm 宽的列,与 ElyMark 的正文宽度设置冲突 | ElyMark 专注模式自己收起标签条、左栏与状态栏,正文宽度由页面设置 |
| CEF 页面的弹出层(`select` 下拉、日期框)在离屏模式另画一层 | 不画;Ely 的页面不用原生 `select`,用自绘菜单 |
| macOS 的 CEF 离屏输入不带 AppKit 编辑命令:Blink 只凭它们处理 Cmd+方向键与 Cmd+Backspace | `ely-web-content` 的 `edit.rs`:页面没处理这些键时照 AppKit 标准键位执行 |
| 表格公式栏与名称框(网格是 LOK 图块,`tables::Spreadsheet` 不用) | `apps/ely-table` |
| Doc/Table/Show 工具栏(`documents::FixedFormatToolbar` 绑 `TextInput`,不接 LOK) | 各 app,用 `shell::Toolbar` 组合 |
| CSV 导入的编码、分隔符、引号、列类型(`misc::CsvImporter` 只读逗号 CSV、映射到属主字段,文案写死英文) | `apps/ely-table` 的 `text.rs`,内核按选项导入 |
| 导出格式与说明(`misc::ExportDialog` 无 TSV,文案写死英文,不能附说明) | 各 app,用 `overlays::Dialog` 组合 |
| 幻灯片缩略图条(`documents::PageThumbnailList` 只收图片路径,无隐藏标记、右键菜单与重排) | `apps/ely-show` 的 `slides.rs`,内核按页绘制到内存图 |
| PDF 页面缩略图(同上,且一次铺全部页,不合 PERF-05) | `apps/ely-pdf` 的 `side.rs`,`uniform_list` 只画可见行,worker 按需渲染 |
| 演示的参考线:LOK 不把参考线画进图块,也无参考线接口 | 补丁 0027 读写参考线;`apps/ely-show` 的 `guides.rs` 画在画布上,拖动移动,拖出幻灯片即删除 |
| `A11yChecker` 的 `audit` 与对照表是私有的;Omarchy 色板要照同一套规则 | `ely-ui` 的 `omarchy/palette.rs` 抄了对照表。上游待办:公开 `audit` |
| 组件无 role/aria 语义:菜单、列表等控件在 AX 树仍不可见 | 上游已修核心控件(7380afb:Button、TextInput、Tabs、面板行、对话框等);其余上游待办。项目自绘控件已自带 |
| gpui 无 aria-disabled API(`AriaProperties` 无 disabled 字段) | 组件 7380afb 起以本地化 aria_description 表达;上游待办:gpui 补 API |
| 组件内部文案未接 i18n(面板族之外:欢迎页标题、菜单项、`SortBuilder` 与 `FilterBuilder` 等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
| LOK 的 `.uno:StyleApply` 状态给界面名(`Default Paragraph Style`),样式列表给程序名(`Standard`),两者对不上 | ElyDoc 属性栏:列表外的当前样式显示为占位文字;更新样式用状态里的界面名。上游待办:状态带程序名 |
## gpui 规则
取自组件库的 `AGENTS.md`、`src/lib.rs` 与 `examples/docs/hello.rs`,应用必须遵守。
- 启动:`gpui_platform::application().with_assets(Assets).run(..)`,闭包里先调 `ely_gpui_component::init(cx)`。没接 `Assets` 会 panic。
- 首帧用 `Theme::set_mode_now`。之后 `Theme::set_mode` 渐变。
- 根元素包进 `FocusScope`,调 `.root()`,聚焦它的句柄。
- 渲染之外改了状态,调 `cx.notify()`。绘制中改的,再调 `window.request_animation_frame()`。不用 `request_animation_frame` 轮询布局。
- 颜色、控件尺寸、圆角、阴影取自 `cx.theme()`。间距用 gpui 的 rem 阶梯。边框用 `border_1`。
- 盖在页面上的东西走 `primitives::raise`。对话框走 `overlays::Dialog`。每个窗口一个 `Toaster`,一个 `ToastViewport`。
- 可按压的元素都是 Tab 停靠点:`tab_index(0)` 加 `focus_ring`。`hover()` 需要元素 id。
- 动效时长取 `motion::duration()`,它遵守减少动效。重复动画在减少动效下静止。动画只包住移动的部分:动画 id 传给子孙,换 id 会重建它们的焦点与拖动状态。
- 文本框是 `forms::TextInput` 实体。文案走 `i18n::I18n`,缺键即失败。
- 可复用组件内的 id 带上属主的 id。`uniform_list` 的行设 `w_full`。盖在内容上的层按需用 `block_mouse_except_scroll`。
## 规则
1. 界面优先用 Ely-GPUI-Components。通用交互回组件库,不在应用里复制。
2. 出错就暴露。不吞错,不写兜底,不用默认值遮挡缺失数据。
3. 修根因。不打补丁式小修。
4. 关键路径有日志(`log`,与 gpui 一致)。日志不含正文、附件、密钥、提示词。
5. 文件不超过 500 行。注释一行,十字以内,只在必要时写。
6. 无 dead code,无 v1/v2 接口,无过期代码。
7. 禁止大框套小框。禁止无用说明文字和小标题。
8. 不过度设计。不加预留扩展,不加无第二个使用者的抽象。
9. 动效只在交互时运行,每段不超过 220 毫秒,遵守系统“减少动效”。空闲时不重绘。目标设备含树莓派。
10. 未验证的能力不写成已完成。
## 命令
```text
scripts/doctor.sh 工具链版本
scripts/fetch-references.sh 取上游源码到 reference/
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
scripts/fetch-cef.sh 取 CEF minimal 包到 engines/cef(随 cef-rs 钉的版本,核对 SHA-1)
scripts/build-git.sh 构建钉住的 Git 到 engines/git(ElyTree 随包)
scripts/fetch-git-lfs.sh 取 git-lfs 放进 engines/git(核对散列)
scripts/review.sh ID 轮次 路径 审查一条任务
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
cargo test -p ely-show --test compose 切换半程帧经 Metal 取像素(主线程,仅 macOS)
tests/scripts/*.sh *.py 脚本测试(缺工具时报 SKIP)
packaging/macos/bundle.sh ely-doc debug 出 dist/ElyDoc.app(带图标、文档类型与该应用的引擎)
packaging/macos/release.sh [批次] 该批各应用的 release 包与 dmg;ELY_SIGN_IDENTITY 加 ELY_NOTARY_PROFILE 即公证
packaging/icons/masters.py 写出 assets/icons 的全部 SVG 母版(图标设计只改这里)
packaging/icons/build.sh 由 assets/icons 的母版渲染 icns、png、ico(只在 macOS)
tests/scripts/icons.sh 八个图标齐全、未过期,macOS 为 squircle,其余无底形
tests/scripts/release-macos.sh dist 里首批六个 .app:以访达的方式打开文件(启动与运行中),引擎都取自包内,带齐许可,不含系统会登记的内嵌包,每个二进制要求的系统不高于应用所写;源码包齐全
tests/scripts/hardened-macos.sh ELY_SIGN_IDENTITY 签名(hardened runtime)的 ElyMark 照样画出页面
tests/engines/*.sh 引擎探测(LOK、MuPDF、libetpan);LOK 探测本构建的内核,不挂载官方镜像
scripts/inert-lo.sh 路径.app 构建出的 LibreOffice.app 去掉文档类型、URL 类型、快速查看与聚焦插件并注销(build-libreoffice.sh 调用)
scripts/extract-official-lo.sh 用 7zz 把官方镜像的 Frameworks 与 Resources 解到 engines/lo-official(不挂载,无包,“未打补丁即拒绝”的单测用)
tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构建出的包不声明任何文件类型
tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板)
tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限)
tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限)
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
scripts/build-mark-content.sh 按锁文件构建 ElyMark 页面到 engines/content/ely-mark(不跑安装脚本)
tests/scripts/mark-content.sh 真窗口:构建的页面离线渲染各包(可视、预览、源码)
tests/scripts/mark-session.sh 真窗口 ElyMark:不改保存逐字节相同,键入一字只改该块(需辅助功能权限)
tests/scripts/mark-modes.sh 真窗口 ElyMark:文档旁图片、三种模式往返、预览只读、跨模式撤销、改名、新建(需辅助功能与屏幕录制权限)
tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转、文件夹搜索、最近列表(需辅助功能权限)
tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制)
tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限)
tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴)
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动
tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载、替换、重新打开(需辅助功能权限)
tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限)
tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
tests/scripts/panels.sh 真窗口 ElyDoc:左侧栏默认不宽于 230、拖动内侧边缘变宽、宽度存入设置、重启后照旧(需辅助功能权限)
tests/scripts/storage.sh 真窗口 ElyMark:设置里的存储列出各类大小,日志当场清空,缓存下次启动前清掉,恢复点数写入设置(需辅助功能权限)
tests/scripts/recovery.sh 真窗口 ElyDoc:恢复点、崩溃后副本、连续两次崩溃、只杀引擎后重新打开(需辅助功能权限)
tests/scripts/pdf-recovery.sh 真窗口 ElyPDF:加锁文件的封存恢复点、空闲引擎退出后重开时再问密码(需辅助功能权限)
tests/scripts/autosave.sh 真窗口 ElyDoc:自动保存开关、停手即写、外部改动不覆盖(需辅助功能权限)
tests/scripts/print.sh 真窗口 ElyDoc:打印用文档自己的纸张,取消不动文档(需辅助功能权限)
tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住整块屏幕(菜单栏与程序坞之上,需辅助功能权限)
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
tests/scripts/webview.sh 真窗口 CEF 页面:输入法组字与提交、键入、焦点、主题变量、缩放、不可离开所在文件夹
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(只有静态资源),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
cargo check --workspace
cargo test --workspace
rm -rf target/debug/incremental 每次构建或测试后都删(用户定:增量缓存会占满磁盘)
cargo build -p ely-lok --bins LOK 单测前先建 worker;单测不会自动重建它
cargo build -p ely-pdf-engine --bins ElyPDF 的 worker(链接 engines/mupdf-build,ELY_MUPDF_DIR 可改;运行时 ELY_PDF_WORKER 可指定)
cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely-lok --bins,LOK 取 ELY_LOK_DIR 或 engines/ 下的构建);ELY_LOG 设日志级别;ELY_SMOKE=1 开窗口、画一帧即退出,ELY_SMOKE=hold 开着不抢焦点;ELY_NO_DIALOG=1 启动失败不弹对话框
```
## 决策
- 2026-10-01:上游版本以 `scripts/fetch-references.sh` 为准。LibreOffice 26.2.6(8221e31)、MuPDF 1.28.5(8ad45e9)、gpui 为 `gpui-pre` =0.3.8 与 `gpui-pre-platform` =0.3.8(crates.io,2026-10-07 起,随组件库 K01)、组件库 5d6efd4(2026-10-09 起)。
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并推送(2026-10-09 起推到 git.zacharyzhang.com,HTTPS,令牌在本机钥匙串)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
- 2026-10-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。
- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high;本机 `codex` 别名已带 `--yolo`,重复传参会报错)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。
- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(macOS 另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定Line truncated
- 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。
- 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
- 2026-10-04:ElyDoc 的标签、会话、打开、保存、另存为(按文件身份比对)、关闭询问、冲突、密码与标签条移入 `crates/ely-office`:应用实现 `Host`(格式表 `Kind`、每标签附加数据、内核回报钩子),共用逻辑以扩展 trait 提供(`use ely_office::prelude::*`)。ElyTable 用同一外壳。共用文案在 `office.*`。
- 2026-10-04:ElyTable(C09.1)。新建为空白 XLSX(首个工作表名随界面语言),保存保留 xlsx/ods/xls/fods,另存为 XLSX(默认)或 ODS,xlsm/xltm 进风险模式。上方名称框与公式栏(回车分别发 `.uno:GoToCell` 与 `.uno:EnterString`,Esc 交回网格),中间行列表头按内核几何自绘(点选整行整列,拖边改尺寸),下方工作表标签(右键与“工作表”菜单:插入、重命名、复制、左右移、隐藏、显示、删除;删除前列出引用该表的公式数与命名区域数)与状态栏(保存状态、内核给的选区统计)。“单元格”菜单:插删行列与单元格、合并拆分(多个数据格时先问移入或保留隐藏)、行高列宽(厘米)与最适、隐藏显示行列、冻结。填充柄拖到目标格发 `.uno:AutoFill`。选区统计文字由内核给出,当前为英文(内核界面语言未随 Ely 切换),待处理。
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案未接 i18n,中文界面下仍为英文(上游待办)。
- 2026-10-04:右侧属性栏宽取 `theme.sheet_size()`(380),左栏仍是 `sidebar_width`。`PropertyGrid` 名称列固定 120,240 宽时编辑器只剩约 76。颜色经色块弹出 `ColorPalette`,一次点击一条命令。
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
- 2026-10-04(用户定):macOS 包标识前缀 `ely.zacharyzhang.office`(`packaging/macos/bundle.sh`);发布者 ZacharyZhang,网址 zacharyzhang.com(macOS `NSHumanReadableCopyright`,Windows 卸载项 `Publisher` 与 `URLInfoAbout`;Linux 桌面文件无此字段)。界面语言英文与简体中文,以后再加。拼写词典只随英文:LibreOffice 的词典库没有中文(中文不用 Hunspell 拼写)。内核界面语言固定英文,函数名照英文;ElyTable 把内核英文的选区统计(Sum、Average 等七个固定名)换成界面语言,数值照内核。批注与修订的作者取各用户的账户全名(无全名用登录名,与内核自身次序一致),由补丁 0013 的 `setOption("author")` 在 worker 启动时设定;作者设置页随设置任务接入。邮件合并字段写法 `«列名»` 由我定(用户交我决定)。
- 2026-10-01:更新清单是 JSON:`{"version", "assets": {"<os>-<arch>": {"url", "sha256"}}}`,只走 https(本机回环除外)。检查、下载、校验在 `ely_platform::update`,从不静默安装。包管理器安装的 Linux 版本不自更新。2026-10-09 起(用户定:0.1.2 加自动更新):每个应用一份清单,`<slug>.json`,随发布上传,应用经 git.zacharyzhang.com 的 API 读最新发布(`api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest`),在其文件里找 `<slug>.json`(`ely_platform::release::asset_url`;Gitea 没有 `releases/latest/download` 路径;`ELY_UPDATE_MANIFEST` 直接给清单地址,测试用);只在从 `.app` 运行时可用(开发构建没有);照 PRD §4.2 自动检查默认关闭:设置“通用”的“启动时检查更新”(通用设置 `check_updates`)打开后启动时查一次、查不到只记日志,应用菜单的“检查更新…”随时手动查(已是最新或查不到都以提示写明);有新版即在窗口上方显示组件库的 `UpdateBanner`(文案进内建中英目录,组件库 5d6efd4):横幅在标题栏下方(`Shell` 的页眉里,`Updates` 自己是一个视图);下载(进度)、校验散列、“重新启动以更新”只走正常的退出流程(有未保存的照常询问;取消即什么也不装,按钮仍在);应用真正退出时(`on_app_quit`)以本程序的安装模式(`--ely-install-update dmg 包 进程号`,`ely_ui::run` 最先识别)起一个独立进程:等本进程结束,`ely_platform::install` 挂载 dmg、把同名 .app 拷到旁边再换掉原包(换上前失败即放回原包),无论成败都 `open` 该包;失败的原因写在数据目录 `updates/failed.txt`,重开的应用以提示写明。下载完成只归属于它下载的那个版本;下载中或已下载时再查不覆盖。`packaging/macos/release.sh` 为每个应用写出清单(版本、本机架构的 dmg 地址与 SHA-256),dmg 里另有指向“应用程序”的替身,可直接拖入。新包换上即为装好,旧副本删不掉只记警告。`tests/scripts/update.sh`:装好的 ElyMark 在默认设置下启动不发任何请求;按“检查更新…”后从本机服务的 9.9.9 下载;有未保存的文字时“重新启动以更新”先问,取消则原包不动,“不保存”后装上新版并重新打开(新版本身的进程在跑)。
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
- 2026-10-05(用户定):八个应用都不许卡顿:键入、选择、滚动、文档渲染都要即时(GPUI 的 120 FPS 手感),树莓派 3 上也要流畅。输入延迟与帧时间是验收项,要实测;界面线程不等内核、不持 worker 锁做同步请求,空闲不做事。
- 2026-10-05:LOK 的输入与事件路径(实测后定)。原样:视图的事件监听长轮询 worker(持 worker 锁最多 50 毫秒,worker 读线程也随之阻塞最多 250 毫秒),按键排在锁后,键入锁等待中位 290 毫秒、最长 9 秒;输入队列在界面执行器上逐条跳回界面线程,每键约一帧,连续键入积压到 1 至 2 秒。现在:worker 有新事件即主动推送(`tag::EVENTS`,推送线程在输出锁下取批,批次即发送时状态),客户端读线程把回复与事件分开,事件存入 `client::Feed`,视图以异步方式等它(`Feed::next`,有新批次时唤醒,不占线程、不碰 worker 锁;原先每个打开的文档阻塞一个后台线程最多一秒,文档多于核数时会饿死输入与图块;监听任务归视图所有,视图释放即停);每个视图一条后台通道(`view/lane.rs`),输入优先、图块其次,连续执行,界面线程只收落地结果;文档尺寸与页框只在内核报尺寸变化时在后台读;批次弄脏的已显示图块即刻重绘,不等下一帧;视图的 `Reported` 事件只在内核回报或输入落地时发出,滚动与图块到达不触发应用的观察钩子;编辑与光标移动后应用要读的内核列表(ElyDoc 导航、字数、审阅,ElyTable 工作表与几何、单元格规则、格式面板)经 `ely_lok::read_off` 在后台读,一次一个,结果按发起者核对。实测(macOS,`tests/scripts/lag.sh`):按打字速度键入时输入落地 0 至 1 毫秒,图块 1 至 3 毫秒,按键到新图块 ElyDoc 中位 19 毫秒、ElyTable 3 毫秒;连续快速键入(脚本一次送出近百字)时,各次运行在 3 至 225 毫秒的中位之间摆动:新文档首次键入时内核有一次 150 至 230 毫秒的冷启动(首块图块与拼写等初始化),同一批字排在它之后;原为中位 1 秒、p95 1.9 秒。树莓派 3 未实测(本机无设备)。界面线程不碰 worker:应用对内核的读取、保存与另存为的导出、导出 PDF、比较、邮件合并都经 `Tabs::read_in`(或 `ely_lok::read_off`)在后台执行,回到窗口时按发起的标签与读取时的状态核对;保存导出期间标签状态为“正在保存”,同一标签的后续保存排队到它结束;另存为在写入前按文件身份再查一次目标是否已被别的标签持有;导出期间又有编辑时,“保存后关闭”不关闭,回到关闭询问;关闭标签时的撤销历史也在后台读;邮件合并预览与比较在发起时就保留自己的标签号与工作目录;读取失败不立即重试,等下一次状态变化;载入时一并在后台读版面与撤销历史(`ely_lok::layout`,`LokView::open` 接收版面)。窗口关闭回调先不放行,在后台逐个读标签的撤销历史并关闭(有改动的照常询问),最后一个标签关闭后移除窗口;内核文档的关闭也在后台(2026-10-06 起,原先同步读历史、在界面线程持 worker 锁关闭)。
- 2026-10-04(用户定):八个应用的内容区一律随系统深浅色:Doc 页面、Table 网格、Show 幻灯片、PDF 页面、邮件正文、Mark 编辑区、Agent 输出。自动颜色随之翻转,文件里写明的颜色照原样;打印与导出 PDF 仍为浅色。各引擎(LOK、MuPDF、CEF)接入应用主题,不留引擎默认色。LOK:`ely.xcd` 定义 `COLOR_SCHEME_ELY_DARK` 与 `COLOR_SCHEME_ELY_LIGHT`(文档底色、自动文字色、网格、链接等取 Ely 默认色板;内核自带的深色方案颜色留空、按应用外观取默认值,传方案名不改外观,所以不用它),`LokView` 在主题深浅变化时经有序队列发 `.uno:ChangeTheme`(不算编辑),页面底色深色取 `surface`、浅色取 `paper`;Omarchy 色板与这两套固定色可能略有差别。导出 PDF 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
- 2026-10-01:主题。`Appearance` 为系统、浅色、深色。系统档在有 Omarchy 时跟随 Omarchy(`ThemeSettings::omarchy_sync`)。读 Omarchy 的 `$HOME/.local/state/omarchy/current/theme/colors.toml`(Omarchy 写死此路径,不随 `XDG_STATE_HOME`),目录由 `ELY_OMARCHY_DIR` 可改。读法与回退链照 `reference/omarchy/bin/omarchy-theme-color`:松散引号、`colorN`、`purple`、`selection_background`、`theme_type`、`light.mode`、亮度和 382 比。缺的色相用 Ely 自己的色并警告。色板照组件库 `A11yChecker` 的规则收口:正文、状态色、代码色在各自的表面上 4.5:1,焦点与图表色在页面上 3:1;先拉近前景再拉到纯黑白,最后逐项核对,核对不过的主题整套忽略。切换时 Omarchy 先暂存 `next-theme` 再整体替换 `theme`,用文件监视跟随,不用它的钩子。新主题无 `colors.toml` 时退回 Ely 色板。`ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION` 是覆盖开关,设置页接入前先用环境变量。
- 2026-10-05:组件库 8658999(组件库重写历史后的 main,加本项目三个提交):属主不经 `Close` 移除的浮层(一起移除的嵌套浮层同样,回到最外层打开前之处)(对话框动作只清自身状态、异步校验通过后才关)照样把焦点交回打开前之处;原先焦点落在根上,键入无处可去,须先点一下网格。同批带入上游的 ColumnShell、输入法组字时光标重新闪烁、`Icon::from_path`、MIT 许可。旧 pin 2f02c82 已不在 main 上,只能按完整哈希取得。`Select::label` 与 `TextInput::label` 给控件无障碍名称而不绘制;对话框里描述列表旁的控件以此命名,开关用自身的 `label`。7186179 加 `NumberInput::label`(经内部文本框的 `TextInput::set_label`),同批带入上游的 rustfmt 修正。80c46b1:`TemplatePicker` 的列表是一个 Tab 停靠点,上下、Home、End 移动,回车使用,行带列表项语义,搜索提示、全部、使用、无匹配的文案进内建中英目录。7df3045:`SelectableList` 在 AX 树里是列表(`label` 命名),`ListItem` 是列表项(标题为名,带说明、选中、在可选列表中的序号与总数);原先行无角色,不在 AX 树里。af54877:`NumberInput::blank` 显示占位文字而非数字(混合选区),空着回车或失焦不提交,键入后照常。408f2b0:`CommentSidebar` 与 `RelativeTime` 的文案进内建中英目录;属主不处理“解决”时(无 `on_resolve`)不分未解决与已解决,一列显示全部。bcf0378:`CommentSidebar` 的批注列是列表,每条是 Tab 停靠点(列表项语义,名为作者与正文,带选中、序号与总数),回车或空格选中。71bfc93:`init_for_tests`(`test-support` 下)装主题与键位、不要资源,应用单测可渲染 Ely 组件。617a8d6:`FindWidget` 在查询框里按 Esc 即关闭(调 `on_close`)。2ce3368:`ContextMenu` 可定宿主大小。3d08744:`DescriptionList` 的值占满所在列,其中的文本框随之全宽;同批带入上游的图表、分页改动。a6b1bb0:`SignaturePad` 的“在此签名”与“清除”进内建中英目录;同批带入上游 P01 至 P15(各组件的越界与失效状态记日志而不 panic)。490b022:`ChangesList` 分列冲突、已暂存、改动、未跟踪、已忽略(各有其动作:冲突与已忽略只可打开),标题与提示进内建中英目录;同批带入上游 P16 至 P20。7bb53f4(ElyTree 用):`ChangesList` 行数可缺、按区报按下、当前行;`DiffViewer` 外算的段、块按钮、可选的变动行、逐行读给辅助技术;`CommitList`、`FileHistory`、`BlameView`、`ThreeWayMerge` 只画可见行,`CommitList` 近尾加载;`DescriptionList::text`;`SegmentedControl` 各段为单选;`BranchList`、`TagList` 可选中、只画属主处理的行内动作;`marked`、`conflicts`、`resolve` 按标记宽度(`MARKER` 为默认 7),a284c71 起只按 LF 断行;c5799cc:`StashList` 文案进内建中英目录,行可选(`selected`、`on_pick`),只画属主处理的行内动作;7fab072 至 3741d5d:分支、标签、储藏的行分两行(上行名称与行内动作,下行说明与行尾文字),窄时名称与说明先让;行内动作一律显示(原先只在悬停时显示,键盘停在其上时看不见),改动列表同;各 git 行带透明的 1 像素边框,焦点框才画得出(原先 `focus_ring` 无边框宽度,键盘焦点不可见);同批带入上游 P21 至 P29。4f8f3bd:`Changed::lines` 可缺(二进制文件、文件夹不计行数,不显示);37c5187 缩短其注释。19b0b29:`DiffViewer::from_stretches`(别处算好的差异,如 Git 自己的)、`hunk_action`(每块标题上一个按钮)、`selected` 与 `on_line`(改动行可按,带 Shift),`diff::mark_words` 给外来的行标出改动的词,“行内”“并排”“N 行未改动”进内建中英目录;30fea9e:有块按钮时各行同高,底色相接。edd2c12:`ChangesList` 每次按下带分区(`ChangeSection`),行是 Tab 停靠点(列表项语义),`selected` 标出当前文件。2e080b8:`SegmentedControl` 每段是单选按钮(名为其文字,带选中状态),原先不在 AX 树里。e226d79:差异查看器的行模型与配色移入 `git/rows.rs`(文件不超过 500 行);7b807a9 缩短其注释。51d2aea:`CommitList`、`FileHistory`、`BlameView` 只画视口内的行(`uniform_list`,填满所在的框;`CommitList` 收 `Rc` 的提交与 `lanes` 算好的图,`on_end` 在接近末尾时要下一页),各行是 Tab 停靠点(列表项语义,选中状态;Blame 每段首行可按,名为作者、时间与摘要),`FileHistory` 的行数可缺;`CommitInput` 的“提交到”“修改最近提交”进内建中英目录。e6220ad 与 3fc5a46:提交行窄时作者、提交号与时Line truncated
- 2026-10-05:ElyTable 透视表与图表(C09.4,TABLE-10、11)。补丁 `0019-lok-calc-pivot-tables.patch` 与 `0020-lok-calc-charts.patch`:`.uno:DataPilotExec` 收 `Pivot`(JSON:源区域、行、列、筛选、值字段与函数 sum/count/average/max/min),光标在透视表上即原地修改,否则从源区域在新工作表创建;行、列、筛选里的空名表示内核的数据布局字段(多个值字段横排或竖排的位置),读出与写回都保留,已在表中的行、列、筛选字段保留各自的小计函数,读出的布局原样写回不改表;照内核布局对话框的步骤(`ConvertOrientation`,保留各字段的成员、排序与版式),新表的筛选按钮、钻取、展开收起都关(同对话框初值);放到新工作表时插表与透视表是两步撤销(同桌面版)。刷新 `.uno:RecalcPivotTable` 在内核里本就不进撤销(它改缓存)。`.uno:InsertObjectChart` 收 `Chart`(JSON:类型 column/bar/line/scatter/pie、标题、图例、X/Y 轴标题;`other` 保持原类型),LOK 下不开图表向导、插入为一步撤销、插入后图表选中;给出的区域每段都要解析得了、所指工作表都要存在,否则插入与修改都拒绝、文档不动(内核原会改用示例数据,修改时 `ChartModel::setArguments` 抛出未捕获的异常使 worker 退出);`.uno:ChangeChartData` 也收 `Chart`,区域与外观合成一步撤销(撤销保存整个图表的图示与主标题的副本,堆积等外观以外的设置一并恢复)(列表动作带当前视图号,否则 LOK 拒绝撤销别的视图的动作)。读取:`.uno:Chart`(选中图表的对象名、区域、表头、外观)、`.uno:PivotTable`(光标处透视表的名称、位置、源区域、字段与布局),`.uno:SelectedRange` 加 `data`(选区内数据的绝对区域)。界面在“插入”菜单:数据透视表、刷新数据透视表、图表;对话框在光标处有透视表、或有选中图表时为修改,否则以选中的数据(单格扩到数据区)新建,源与图表区域取 `data`,不含空行。为对话框发起的读取(`Tabs::ask_in`)回来时,发起的工作簿已不是当前标签即丢弃,不为隐藏的工作簿开对话框(ElyTable 全部对话框、合并询问)。没有数据时恢复原光标(`select.data` 会把单格标成区域,回车随之只在格内打转)。公式语法是 Excel A1,区域写 `Sheet1!$A$1:$C$5`。
- 2026-10-05:ElyTable 文本文件、导出、打印、保护、大表(C09.5,TABLE-12 至 16,§9.3)。补丁 `0021-lok-calc-page-setup-protection.patch`:`.uno:PageFormatDialog` 收 `Page`(JSON:横向,百分比、宽高页数或总页数之一),一步撤销;`.uno:Protect` 收 `Protection`(JSON:带密码与八项许可保护,或凭密码取消,密码错不改动);`.uno:CellProtection` 带值时按值设锁定(原样只切换);读 `.uno:PrintLayout`(打印区域、重复行列、方向、缩放、共用页面样式的工作表、光标处手动分页)与 `.uno:SheetProtection`;修上游:缩放改动后 `InvalidateTextWidth` 按整表列数遍历未分配的列,越界崩溃。`0022-lok-calc-text-files-and-links.patch`:LOK 下 CSV 带选项载入不再弹导入对话框(原样照弹,模态挂死 worker);`saveAs` 的过滤器选项原样交给过滤器,只去掉 LOK 自己的关键字(原样丢空项、插空格,CSV 选项错位);读 `.uno:ExternalLinks`(外部引用、区域链接、表链接、DDE、WEBSERVICE 的来源)。LOK 载入选项可带 `FilterName`(原样只按扩展名猜,`.txt` 落进 Writer)。worker 的 CSV 导入与导出只收类型化字段(`IMPORT`、`EXPORT_TEXT`),选项串与 Calc 文本过滤器名在 worker 内生成,载入后不是表格即关闭并报错。CSV 规则由我定(用户未定):打开 csv、tsv、tab、txt 先出导入对话框,选编码(UTF-8、UTF-16、GB18030、Big5、Shift_JIS、EUC-KR、Windows-1252、Windows-1251、ISO-8859-1;有 BOM 按 BOM,合法 UTF-8 即 UTF-8,否则中文界面 GB18030、英文界面 Windows-1252)、分隔符(tsv、tab 为制表符,否则取首行最多的逗号、分号、制表符、竖线,都没有为逗号)、引号、逐列类型(常规、文本、三种日期顺序、跳过),预览与类型推断按 Calc 文本导入的规则拆字段(`impex.cxx`:引号前的空格跳过、引号后的文字并入、双写引号转义、空行算一行),后台读整个文件,严格解码(有字节读不通即提示并禁用打开,不以替换字符静默改写),预览前 8 行、每格 200 字,每列都有类型选择;打开时导入的就是检查过的那份字节(对话框期间源文件再变也不读);整个文件里出现前导零、加号开头、超过 15 位数字(可带负号)的列默认为文本;数字按 en-US 读,日期只在标为日期的列按所选顺序读,不识别特殊数字,`=` 开头的文字不作公式。导入得到未命名的新工作簿(标签为原文件名),保存走另存为 XLSX,原文件不动。导出(Cmd+E):PDF(全部工作表,按页面设置)、CSV、TSV;文本导出只写当前工作表的显示值(UTF-8,无 BOM),对话框写明导出哪张表、公式格式与其他表不保留;内核“只存当前表”的警告框由 `ely.xcd` 关掉。打印(Cmd+P)先导出同一 PDF 再交系统打印面板。“页面”菜单:页面设置(打印区域写作 A1:D20,多个用逗号;重复行 1:2;重复列 A:B;方向;缩放),插入、删除行列分页符(光标处没有即提示);页面样式由多张表共用时对话框写明作用于哪些表。“工作表保护…”在工作表菜单,“锁定单元格”“解锁单元格”在格式菜单。§9.3:`ely.xcd` 设 XLSX 与非 LibreOffice 生成的 ODF 载入时总是重算(原样信任文件缓存,实测 Excel 留下的 `=1+1` 过期缓存 7 与未知函数缓存 42 都当结果显示);不支持的函数显示 `#NAME?`,原公式保留(函数名存成小写),状态栏解释错误;其缓存值不保留,另存写成错误值(已知限制)。外部链接照内核“按需更新”不刷新,显示保存时的值,工作簿有链接时上方提示条列出来源(每次编辑后在后台重读)。NOW、TODAY 用系统时区;1904 日期系统随文件,未专门验证。视图只请求屏上的图块,离屏超过一屏的图块丢弃(原样只增不减,滚动长表内存随之涨);行列表头只画可见的。
- 2026-10-05:ElyShow 应用壳、幻灯片管理、模板与母版(C10.1,SHOW-01、02)。补丁 `0023-lok-impress-slide-design.patch`:`.uno:PageSize` 改走页面对话框的 `SetPageSizeAndBorder`(原样走 `SetupPage`,不进撤销);`.uno:PresentationLayout` 收 `Master`(母版名,套到当前幻灯片,一步撤销;不存在即拒绝);`.uno:ThemeDialog` 收 `ColorSet`(内核配色名,套到当前母版,一步撤销);读 `.uno:SlideDesign`(母版列表、当前母版、是否在母版视图、当前版式、配色列表与当前配色)。内核参数类型:`SfxUInt32Item` 取 `long`(传 `unsigned long` 时参数被丢弃,命令仍跑出一步错误的撤销)。缩略图经 worker 的 `PAINT_PART`(`paintPartTile`,第二参是编辑模式:0 幻灯片,1 母版),按名缓存,编辑时只重画当前页,结构、尺寸、配色变动时全部重画,内核换深浅色方案后全部重画(首帧先于方案落地)。母版视图的进出内核不发重绘,按设计读出的编辑模式判定,进出后整屏作废重画(`LokView::invalidate`)。演示的画布按视口缩放整页并居中(`LokView::fit_page`)。新建:内核自己的空白演示(`private:factory/simpress`,16:9,标题版式)每次运行写一次到工作目录,再按副本打开;或内核随带的 Impress 模板(`template/common/presnt`)。版式取内核版式面板的 12 种;尺寸预设宽屏 16:9、16:9、4:3、A4、Letter,另可输入厘米。章节(用户定为外部阻塞):内核无幻灯片章节,PPTX 导入丢弃 `p14:sectionLst`,ODF 无此结构。补丁 `0024-lok-math-tiles-keep-the-painted-tile.patch`:图块收尾时公式对象助手以默认擦除重设设备尺寸,文字编辑中的 Impress 图块整块被擦(macOS 上为黑);改为不擦,同图表助手。离开文字编辑(内核收起插入点,`LokView::caret`)时重画当前页缩略图,编辑中画的缩略图带编辑框。`0025-lok-impress-paints-the-current-view.patch`:Impress 的 `GetViewShell` 原取文档外壳所连的视图,外壳栈重排(`ViewShellManager::MoveToTop`)时内核先断开再接回,其间到达的绘制得到空白图块(macOS 上为黑),按键丢失;LOK 下改取当前视图的主外壳。`0026-lok-impress-settles-panes-before-commands.patch`:Impress 的窗格重配排在用户事件里,先到的命令找不到幻灯片排序外壳,隐藏、显示等槽被丢弃;`ITiledRenderable::settleViews` 在 `postUnoCommand` 派发前执行,Impress 在此同步处理排队的配置。两者在八路并行下实测(各 96 次全绿,修前约四十次一败)。缩略图失效按撤销与重做列表的条目判定(`ely_lok::history::steps`):撤销列表有条目消失即撤销,重做列表的条目进了撤销列表即重做,撤销后立即编辑也能识别。
- 2026-10-05:ElyShow 文字、图形、图片、对象操作(C10.2,SHOW-03 至 06)。补丁 `0027-lok-impress-objects-guides.patch`:读 `.uno:SlideObjects`(当前页各对象的种类、外框、占位符角色、锁定、是否选中,选区的种类、填充、轮廓、透明度、阴影、自动适配、外框、旋转与中心、锁定、能否组合与取消组合,网格与参考线吸附,参考线);`.uno:SelectObject` 收 `Index`、`Add`(按层次选中);`.uno:CapturePoint` 收 `Kind`、`X`、`Y` 添加参考线,收 `Index` 加坐标或 `Delete` 移动或删除(LOK 无参考线对话框);`.uno:TransformDialog` 收 `ProtectPos`、`ProtectSize`;`.uno:TextAutoFitToSize` 收 `Fit`(off、shrink、grow,对所有选中对象一步撤销,横向增长一律关,撤销连同文字恢复;原样是只对单个对象的开关);`.uno:ChangePicture` 收 `FileName` 原位替换;LOK 下插入图片放在幻灯片中央(原用窗口可见区中心,LOK 下无意义)。LOK 把 `TransformDialog` 的位置、尺寸与旋转中心当 twips 读入,读出是 1/100 mm,面板发送前换算;线宽与参考线不换算。插入菜单:文本框、矩形、椭圆、三角形、直线、箭头、连接线(内核直接在幻灯片中央创建)、图片;视图菜单:吸附到网格、吸附到参考线(勾选随内核)、添加水平或垂直参考线。右侧面板选中对象时为对象属性(位置、大小、旋转、锁定;填充、透明度、阴影、轮廓与线宽;文字的字体、字号、样式、颜色、对齐、列表、自动适配;图片的替换与裁剪;层次、对齐、分布、组合),否则为设计;两者下方是对象列表(可多选,按层次选中)。数值框在回车与随后的失焦各提交一次时只发一次:按字段记住已发值与发出时读到的值,到下一次读取(按标签与读取次数)为止;混合值的数值框为空并显示“混合”(`NumberInput::blank`),空着回车或失焦不发送,不作去重的依据。色块的清除按钮发各自的命令(无填充、无轮廓)。直线、箭头、连接线与图片只显示轮廓与线宽。对象列表取消全部选中即清除内核选区(`.uno:SelectObject` 只带 `Add` 时不选任何对象)。补丁按序列打:`scripts/apply-lo-patches.sh` 找出源码已等于“原版加前 N 个补丁”的 N,再打其余;原先逐个反向检测,补丁重叠后重跑构建失败。面板与菜单的命令不经视图输入,缩略图改按撤销列表的变化判定当前页作废。ElyDoc 属性栏的内核控件(开关、按钮、色块、字体、字号)移入 `ely_office::panel`,ElyShow 共用。连接线接到图形靠在画布上拖动端点,未单独验证粘附。
- 2026-10-05(用户定):ElyShow 的音视频由各平台原生播放:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(同 LibreOffice 自己的 avmedia 后端),H.264/AAC 解码与许可随系统;Ely 一个接口,三份实现。LOK 不放媒体。
- 2026-10-05:ElyShow 动画、切换、媒体(C10.4,SHOW-09、10)。补丁 `0033-lok-impress-slide-layers-on-macos.patch`:放映图层渲染(`SlideshowLayerRenderer`)把内核画进客户缓冲区,Aqua 设备画不进,macOS 上每层都空;改为画进自带透明度的设备再拷出(同图块)。`0034-lok-impress-transitions-and-animations.patch`:`.uno:SetSlideTransition`(JSON:预设、时长、单击或自动及秒数、全部幻灯片)、`.uno:AddAnimation`(给选中对象加预设)、`.uno:ChangeAnimation`(开始方式、时长、延迟、前后移、删除),各一步撤销;类型不合或内核无此预设即不改;读 `.uno:SlideTransition` 与 `.uno:SlideAnimations`(每个效果的目标按 `.uno:SlideObjects` 的序号,并给放映图层的散列,同 `anim::convertTarget`)。`0035-lok-impress-media.patch`:`.uno:InsertAVMedia` 收 `Width`、`Height`(1/100 mm)时不弹媒体对话框、不经内核播放器量尺寸,放在幻灯片中央;读 `.uno:SlideMedia`(序号、URL、是否链接、实际播放的文件:链接即原文件,嵌入即内核的临时副本、文件是否缺失)。Ely 只给基础一组(切换:无、淡化、经黑色淡化、向左推入、向上推入、向左覆盖、向左揭开、向右擦除、向外分割、溶解;动画:出现、淡入、飞入、擦入、放大进入、浮入、放大缩小、闪烁及对应的退出),文案由 Ely 给出;文件自带的其他效果显示内核名称。预览不阻塞编辑:worker 的 `SLIDE_LAYERS` 在后台取图层(静止层、动画对象各一层、母版字段占位),Ely 按效果排时(单击接在前面全部之后,与上一同时,在上一之后)在画布上合成:淡入淡出改透明度,飞入飞出与浮入浮出移位,擦除裁剪,放大缩小与强调缩放,闪烁隐显;预览里不画日期、页脚、页码字段(内核留给客户端画)。切换预览用两页的图层:新页按初始可见,旧页对象全显示(只读当前页的效果,旧页的退出不知道);首页从黑开始;无填充的页铺页面底色(`ely_lok::paper`,同编辑区)。媒体(用户定各平台原生):`ely_platform::media::Player`,macOS 以 AVFoundation(`AVPlayer` 加 `AVPlayerItemVideoOutput`,帧为 420f 的 `CVPixelBuffer`,经 GPUI 的 `surface` 画在对象上),状态有载入、可播(时长、画面尺寸)、失败(系统给的原因);Windows 与 Linux 尚未构建,打开即报“此平台未构建”。插入:选文件后先由系统播放器打开,可播才插入(视频按 96 dpi 换算并缩进幻灯片八成之内,声音 2×2 厘米),不能播即提示原因、不插入;默认嵌入,随文档保存。选中媒体对象时属性栏给播放、暂停与时间;链接文件缺失或系统不能播放时写明。正在播放的媒体按标签、幻灯片名与媒体 URL 认定,不符即停;播完再播从头开始。预览按标签、幻灯片名与撤销列表认定,读取回来、每帧绘制时不符即弃;停止令在途读取作废。向左揭开时旧页叠在新页上。面板的秒数字段经 `object::commit` 去重(回车与随后的失焦只发一次);切换的各字段以已发未读回的切换为底(`Extra::transition_asked`,带命令的输入序号;命令落地后开始的读取回来或换页即清,撤销也随之生效);秒数去重的键带幻灯片,动画的还带效果序号与图层散列(`motion::effect_key`)。预览另按视图的已落地与待落地编辑认定。播放实例在文档每次通知时核对(`observe_self`),标签关闭即停。
- 2026-10-05:ElyShow 表格与图表、备注与审阅(C10.3,SHOW-07、08)。补丁 `0028-svx-chart-look-helpers.patch` 与 `0029-svx-chart-data-helpers.patch`:图表的外观与数据读写移入 svx 的 `ChartHelper`(Calc 与 Impress 共用;JSON 经 boost.json 保留类型,类型不对即拒绝,改动之前判定);`0030-sc-chart-looks-from-svx.patch`:Calc 改用它,原代码删去;`0031-lok-impress-charts-notes-comments.patch`:`.uno:InsertObjectChart` 收 `Chart`(外观与数据的 JSON),不开图表界面,落在幻灯片中央,选中一个图表时就地修改(复制后替换,一步撤销),JSON 不合即拒绝;先设外观再设数据(数据按图表类型写);读 `.uno:Chart`。缺失的点读写为 null(图表自己的非数标记),对话框里是空格;散点图的类别即 X 值:须为数字,按数字写入(原样写成文字,PPTX 导出为文字引用,再打开 X 变成 1、2、3);PPTX 打开的散点图按系列角色读出,各系列共用一组 X 时以 X 为类别,各系列 X 不同时不是一张表,读出不带 `data`,对话框只改外观并说明。`0032-oox-scatter-x-values-as-numbers.patch`:散点图的 X 全是数字时导出 PPTX 为数字引用。只在图表变成散点图时设为只画点,原是散点图的保留连接线;应用对散点图先查 X 是否数字,指明哪一格。原有的 `attachData` 去掉:各写入已自行生效,它反把数字类别写回文字;新命令 `.uno:SlideNotes` 以纯文字设 `Part` 页的备注,一步撤销(备注随所属页提交,切页排在失焦提交之前也不错页);读出当前页序号、文字与是否“带格式”。PowerPoint 给每段文字都写全字符格式,所以“带格式”指段落格式或字符格式在文中不一致:一致时新文字沿用它,不一致时拒绝(纯文字会丢格式,界面只读并说明)。批注带所在幻灯片序号;视图无作者时新批注取用户名(原样留空,补丁 0013 设的用户名到不了 Impress)。0027 另加:`.uno:SlideObjects` 不列批注(26.2 的批注是绘图对象),`.uno:SelectObject` 的序号同样不数批注。共用:`ely_office::chart`(图表对话框的外观字段,ElyTable 与 ElyShow),`ely_office::panel`(内核状态驱动的面板控件,ElyDoc 属性栏与 ElyShow 对象面板)。界面:“插入”菜单的表格(列 1–20、行 1–50)、图表(外观与图表自带的数据表,类别乘系列,可增删;非数字的格拒绝并指明哪一格;选中图表时为修改)、批注;表格对话框的行列数键入即生效(不等失焦);读选中图表时在同一次 worker 调用里读出当时选中的对象(页、在列表中的序号、外框),与发起时不符即丢弃,建对话框前再按界面所见核对一次;对象面板的表格组(行列插删、合并、删除表格,可否由内核状态定,光标进单元格后可用);画布下方备注栏(失焦提交);“视图”菜单的批注栏(`collab::CommentSidebar`:选中即转到该页并点亮画布上的标记,编辑、删除;只在所在页显示为选中,离开后可再点回)。不做回复:Impress 无回复结构,内核的回复把原批注改写成英文引语加回答;PRD 未要求。备注不上观众屏随放映(C10.5)。
- 2026-10-06:ElyShow 放映与演讲者视图(C10.5,SHOW-11、12)。补丁 `0036-lok-impress-slide-show.patch`:读 `.uno:SlideShow`(每张幻灯片的隐藏、备注文字、切换与动画,同 `.uno:SlideTransition`、`.uno:SlideAnimations` 的形状,媒体带外框);`.uno:SlideMedia` 每项加外框(1/100 mm)。放映用内核的放映图层(`SLIDE_LAYERS`),不是截图轮播:母版的日期、页脚、页码由内核另画成文字层,`ely_lok::show::layers` 把它放到母版占位处的层次上(该页不显示的字段无文字层,即不画)。“放映”菜单:从头开始(F5,从第一张未隐藏的)、从当前幻灯片开始(Shift+F5,隐藏的也从它开始);开始时等该标签已发的输入落地,在同一次 worker 调用里读当前页、把文档另存为工作目录里的 ODP 副本(每次放映一个文件名)、载入副本并按应用深浅色设方案、读全部幻灯片;放映的图层、动画与嵌入媒体都来自副本,编辑不进入本次放映,放映结束时关闭并删除副本。启动中与放映中再按开始不响应。单击组从此前全部已触发动画与入页切换都结束后才开始。单击、右、下、空格、PageDown、N 下一步(先放本页的单击动画组,再到下一张未隐藏的幻灯片,最后是黑色结束屏,再一下退出);左、上、PageUp、Backspace、P 回到上一张并显示为全部动画已完成;数字加回车跳到第几张(含隐藏的);Home、End;B 或句号黑屏;L 激光笔;Esc 退出(有已键入的数字时先清掉)。与“上一个”同时、在其后开始的动画并入前一单击组,第一组在进入本页(切换结束)时开始;自动换片在本页动画全部完成后再等所设秒数。切换按进入方式:下一步有切换,跳转(含跳到本页)与后退无;离开的页按离开那一刻的动画状态画在切换里。媒体载入或播放失败即停,演讲者视图(单屏时观众屏)写明原因,放映继续。后退显示该页全部动画已完成。本页图层到达后才开始入页切换与动画计时;切换结束即释放离开页的图层。`Player::tick` 判定循环重播与失败,`Player::ended` 判定播完(人按的暂停不算播完;编辑区与放映共用);定位为精确定位(容差为零)。单击媒体对象播放、暂停或播完重播,离开本页即停;视频帧画在对象上。多屏:屏幕多于一块时先问观众屏与是否开演讲者视图(演讲者视图开在另一块屏上),两窗口都全屏;演讲者视图有当前页(鼠标在其上即为观众屏的激光点)、下一页(初始状态)、备注、计时(自开始起)、时钟、页码、上一步、下一步、黑屏、激光笔、结束放映。备注只在演讲者视图,观众屏从不画备注。图层宽度取观众屏上幻灯片的两倍像素,不超过 1920;只缓存当前页与下一页。GPUI 无隐藏光标的样式:单屏放映开激光笔时系统光标仍在。应用单测以组件库 `init_for_tests` 装主题,可渲染放映窗口。
- 2026-10-06:ElyShow 输出与演示检查(C10.6,SHOW-13、14,QA-12)。补丁 `0037-pdf-impress-handouts.patch`:PDF 导出的 `ExportHandouts`(每页张数 1、2、3、4、6、9)照打印的讲义版式(`SdPage::CalculateHandoutAreas`,3 张带笔记线)把 `PageRange` 内未隐藏的幻灯片排到讲义页上;其他张数不出页,导出失败。讲义版式会改文档的讲义页,所以讲义与图片都在另存的 ODP 副本上做(`output::on_copy`,做完关闭并删除)。`Pdf::handouts` 经 worker 的 `EXPORT_PDF` 传入。导出对话框:内容 PDF、图片、讲义,范围全部、当前、从–到,讲义每页张数(默认 3);打印对话框:幻灯片或讲义,生成同一 PDF 后交系统打印面板。图片每张未隐藏的幻灯片一个 PNG,宽 1920,副本设浅色方案,文件名 `名称-序号.png`,所选文件夹里任一目标已存在即整体不写。PPTX/ODP 由保存与另存为负责。补丁 `0038-lok-impress-slide-check.patch`:读 `.uno:SlideCheck`(每张幻灯片与母版:文字所用字体族、链接图片与 OLE 链接及是否缺失、内核不能显示的对象:读不出的图片、无数据的 OLE;缺失的链接图片只作为链接报一次)。演示检查以内核报的缺失字体(`FONTS_MISSING`)为准,按所用字体定位到幻灯片或母版;媒体缺失与媒体链接取自 `.uno:SlideShow`;点一行转到所在第一张幻灯片。系统打印面板只经 `ely_office::print_later` 打开:它在前台任务里、GPUI 放开应用之后运行(面板的模态循环会回调 GPUI,在实体更新里打开即借用冲突崩溃),三个应用共用。图片输出先以 `create_new` 占下全部目标(不跟随符号链接),任一冲突或失败都删去本次占下的文件。输出与检查的对话框由后台读取打开,回来时须仍是最近一次请求(`Deck::ask_dialog`、`dialog_due`)且没有别的对话框;对话框种类或当前标签一有变化,此前的请求即作废(`watch_dialogs`,随文档通知)。图片的张数与隐藏取自写出的副本本身;PNG 写完显式 flush。读不出的图片按载入后的状态判定(`NONE` 或解码失败的 `Default` 占位);OLE 链接检查源文件是否在。
- 2026-10-06(用户定):ElyPDF 签名与权限。验证签名时证书信任取操作系统的信任库(macOS 钥匙串的信任设置;Windows、Linux 随其构建)。可视签名三种来源:手写、键入姓名、图片文件;可视签名不是数字签名。只凭用户密码打开、带所有者限制的文件照限制办(打印、复制、编辑等被拦并说明原因),输入所有者密码可解除;改保护须所有者权限。
- 2026-10-06(用户定):OCR 随包语言数据用 tessdata_fast,英文、简体中文、繁体中文(eng、chi_sim、chi_tra)。
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;CEF 内容宿主随 ElyMark 先建(C14.0),邮件编号依次前移。
- 2026-10-06(用户定):`ely-office` 的外壳不再只服务 LOK:`Host` 带关联类型 `Engine`(`ely_office::Engine`:后台载入、视图、撤销历史的标记、写出、关闭,视图的落定、编辑计数、待落地、焦点与事件),`Tab` 与 `Core` 按引擎取文档、视图与 worker 类型。LOK 的实现在 `ely_office::lok`(`Lok`、`start`、只对 LOK 的 `Commands` 即 `run_active`/`run_in`;查找替换、面板、图表仍只限 LOK);MuPDF 的在 `apps/ely-pdf/src/engine.rs`(`Pdf`;worker 首次载入才起,`Mupdf::get`)。`Kind::macros` 为 `None` 表示该格式无宏,不扫文件。ElyPDF 每个标签一个 `PdfView` 实体(页面、图块、文字、选择、找到的词都在里面,自己绘制),`Viewer` 只管标签条、书签栏、查找框与状态栏;打开、密码、关闭询问与保存走外壳。外壳的密码框:对话框开着时后到的加锁文件排队(`Core::locked`),对话框关闭(含取消、提交)后下一个再问;每次询问一个身份(按输入框实体),连续询问的 Esc 各归各的;提交时对话框先关、再载入。ElyDoc 的加锁测试原先清的是应用自己的对话框字段,改为清外壳的。
- 2026-10-07:本地 Web 内容宿主(C14.0,§17.3)。`crates/ely-web-content`:CEF 154.0.32(Chromium 154.0.8037.58,cef-rs a712ad9,`default-features = false`),`scripts/fetch-cef.sh` 取 minimal 包到 `engines/cef`,`.cargo/config.toml` 的 `CEF_PATH` 让构建用它。进程:应用的 `main` 先调 `ely_web_content::subprocess()`(参数里有 `--type=` 即为 CEF 子进程,载入框架后 `execute_process`,不碰 GPUI);开发构建子进程即应用本身的二进制;macOS 包里是五个辅助应用(`<二进制名> Helper`、`(GPU)`、`(Renderer)`、`(Plugin)`、`(Alerts)`,`Contents/Frameworks`,`LSUIElement`,可执行文件为 `ely-web-helper`),框架在 `Contents/Frameworks`,`packaging/macos/bundle.sh` 对依赖 ely-web-content 的应用自动放入并先签内层;查找次序 `ELY_CEF`、包内、辅助应用所在、构建时的 `engines/cef`。启动:`start` 在 GPUI 里只做准备(载入框架、给 NSApp 的类补上 Chromium 要的 `isHandlingSendEvent`、`setHandlingSendEvent:` 与包住 `sendEvent:`,并加 `CrAppProtocol`、`CrAppControlProtocol`),`initialize` 与建浏览器都在消息泵任务里、GPUI 的更新之外进行(实测:在更新里同步调 CEF,CEF 转动 AppKit 的循环,GPUI 报 RefCell 已借用、整窗不画);浏览器异步建(`on_after_created` 交回)。凡在 GPUI 里对 CEF 的调用(输入、焦点、尺寸、脚本、缩放、重新载入、关闭)都排进主线程的队列,由泵在下一轮、更新之外依次执行(实测:在实体释放时同步关浏览器,CEF 转动 AppKit 的循环去校验菜单,GPUI 的菜单回调遇已借用的应用状态而中止,约三成复现)。泵:外部消息泵,按 `on_schedule_message_pump_work` 的时间,另照 cefclient 每轮后以 1/30 秒兜底(实测:只按请求时第一轮后 CEF 再不请求,页面不载入);空闲时 CPU 约 1.5%。离屏渲染须用 Alloy 风格(`runtime_style`;Chrome 风格不出画面)。同一缓存目录只允许一个实例,第二个启动时报出占用它的进程号。画面:`on_paint` 的 BGRA 直接成 GPUI 图像,上一帧在下一次绘制时从图集释放(`drop_image`);光标随页面。输入:鼠标点击、移动、滚轮(行按 40 点),键照 cefclient 的 macOS 做法:键盘按下带字符与系统键码发 `KEYDOWN` 再发 `CHAR`(退格、回车、方向等用 AppKit 的字符),可打出字的键等 GPUI 的输入法回调后以所打的字发同样两个事件,多字或组字经 `ime_commit_text`/`ime_set_composition`(无替换范围时用 CEF 的“无范围”,两端为最大值;传空指针 CEF 不收);松开带同一字符(不带时 Chromium 当作又按一次,实测退格删两个字);拷贝、剪切、粘贴、全选走框架命令。焦点随 GPUI 的焦点给页面。页面与应用之间:CEF 的消息路由,页面调 `window.elyQuery({request, onSuccess, onFailure})`(`onSuccess` 必须有,缺了路由不发),应用收到 `Event::Query(请求, 回复)`;应用以 `run_js` 调页面。主题:每次载入与主题变化时把调色板、代码色、字体与字号比例、深浅写成 `:root` 的 `--ely-*` 变量与 `color-scheme`。缩放 `set_zoom(倍数)`。页面只许载入所在内容文件夹里的文件:按真实路径判定(`..`、百分号转义、指向外面的符号链接都算外面,文件须存在),导航与每个资源请求(脚本、图片、样式、请求等)都过这道门,资源另许 `data:` 与本页的 `blob:`,其余一律取消;另在每个 CEF 进程以 `--proxy-server=127.0.0.1:9` 与 `--proxy-bypass-list=<-loopback>` 把一切网络(含本机回环与 WebSocket,后者不经资源回调)送往不通的代理;WebRTC 走 UDP 不经代理,故另以 `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` 只许它经代理,并在每个页面上下文建立时、页面脚本之前删去 `RTCPeerConnection` 等构造(渲染进程的 `on_context_created`);页面的地址由真实路径逐字节转义生成(文件名含 `#`、`?` 照样载入);用户点出的链接以 `Event::Leave` 交给应用;页面的警告与错误写入日志。载入失败(主框架,停止不算)、渲染进程退出、CEF 起不来都报 `Event::Failed`,页面区改显示组件库的错误视图与“重新载入”(浏览器在即重载,否则重建;CEF 起不来时只说明),载入成功才报 `Event::Loaded`。浏览器关闭时断开它与表面的互相持有,页面的内存随之释放(释放时写日志)。真窗口(macOS,开发构建与 `dist/ElyMark.app`):页面以应用深色主题画出,点选、键入中英文、回车、退格、方向键、拷贝粘贴、`elyQuery` 往返;`tests/scripts/webview.sh` 以输入法回调组“你好”、键入、读回焦点、主题变量与缩放 1.25,核对页面跳不出Line truncated
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1);状态栏显示所用 Git 的路径与版本。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
- 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。
- 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据、CEF 与页面的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);CEF 的 LICENSE 与 Chromium 的 CREDITS、页面的第三方通知;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。正式签名(hardened runtime)时 CEF 的渲染与 GPU 辅助程序带 `allow-jit`,插件辅助程序带 `allow-unsigned-executable-memory` 与 `disable-library-validation`(`packaging/macos/entitlements`;原先不带,V8 保留不到代码区,页面载入失败);`tests/scripts/hardened-macos.sh` 以 `ELY_SIGN_IDENTITY` 签 ElyMark 并核对页面画出。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
- 2026-10-08(用户定):深色主题不用纯黑或近黑,底色取柔和的灰 `#252525`。组件库 fd0efc7:深色色板 bg `#252525`、surface `#2c2c2c`、sunken `#202020`、overlay `#323232`、hover `#383838`、active 与 border `#3f3f3f`、border_strong `#555555`;为让说明文字在较亮的 hover 与 active 上仍有 4.5:1,muted 提到 `#c4c2c0`、subtle 与代码注释提到 `#acaaa8`;色盲友好图表的紫与橙改为 `#9d8dec`、`#ffd93d`(3:1 且各色两两分得开)。`ely.xcd` 的深色方案随之:页面 `#2c2c2c`、页外 `#252525`、边界与网格 `#555555`、域底纹 `#3f3f3f`。跨应用同步外观、设置里的浅色/深色/跟随系统、Omarchy 下随 Omarchy 主题,本来已有(共用 `settings.json` 加文件监视;`settings.sh` 核对 ElyPDF 选深色后 ElyTree 即刻变深);Omarchy 的读法与 OmaPhoto 相同(`~/.local/state/omarchy/current/theme/colors.toml`、`mode` 与具名颜色)。
- 2026-10-08:图像的显存与内存(C20.2 长时测试查出)。GPUI 把每张画过的 `RenderImage` 留在窗口的图集里,直到调 `window.drop_image`;LOK 的图块、PDF 的图块与缩略图、演示的缩略图、动画预览与放映图层原先都不释放,ElyDoc 编辑保存 60 轮后应用进程涨到约 2 GB(内核不涨)。组件库 `primitives::Retired`:不再画的图先交给它,下一次绘制时释放(`release` 只放别处已不持有的,`release_all` 给自知不再画的属主,`append` 接过关闭者的);LokView、PdfView 在替换、挤出、清空图块时交出旧图,绘制前释放;ElyShow 每个标签一份,关闭的标签与结束的预览交给 Deck;放映按窗口各一份(观众屏、演讲者视图),离场中的幻灯片还在画时其图层等它结束。修后同样 100 轮在 340 至 440 MB 之间起落。
- 2026-10-08:性能基准(C20.1,§20.1)。`tests/perf/bench.py` 在发行构建上测 PERF-01 至 08(每项 30 次,P50、P95,含全部子进程的内存),样本由 `tests/perf/samples.py` 按固定种子生成到 `engines/bench`,记录写到 `docs/perf/<日期>-<机型>.md`(格式见其 README);`ELY_STALLS=1` 开主线程忙碌记录(只供测量,空闲的应用照样不做事)。实测改动:Doc、Table、Show 原先要等内核启动完(约 2.5 至 3.9 秒)才开窗口,ElyDoc 日常启动 P95 3.9 秒不达标;现 `Worker::launch` 只起进程,内核的版本与图块核对在第一个请求前做(`Worker::ready`,任何请求都先过它,未核对的 worker 收不到请求),`ely_office::start` 在后台核对并设作者名,失败即 `startup::fail`;窗口约 0.15 秒可操作,空白文档在内核首次载入后出现(中位约 2.7 秒,其中约 2.2 秒是内核首次载入文档)。启动参数改为按当前目录取绝对路径(原相对路径成了 `file://相对` 而打不开)。补丁 `0039-skia-macos-no-unused-font-manager.patch`:macOS Skia 首次画字时以全部系统字体再建一个无人使用的 `SkFontMgr`(约 370 个字体枚举加排序,主线程约 175 毫秒),新文档的第一个字与打开文档的首块带字图块因此迟到;去掉后 PERF-03 的 10 次对比 P95 由 2.6 秒降到 1.5 秒。PERF-02 的计时原以输入落地时的代际为基准,内核的作废事件先于落地回报到达时,该次编辑的图块不计,钟一直走到下一次按键(记下的是按键间隔约 250 毫秒);现按编辑的修订认定(`view/clock.rs`:修订不低于该编辑的批次所升的代际,其图块即为所求),实测中位 2 毫秒、P95 5 毫秒。PERF-08:关闭的标签原把其图块留在 GPUI 的图集里(每个文档约 20 MB 显存,40 次新建关闭后应用涨约 250 MB);`Engine::retire` 交出关闭视图的全部图像(LOK 图块、PDF 图块与缩略图、ElyMark 页面的画面),外壳在窗口的下一帧释放,修后 40 次在 454 至 462 MB 之间。历史首屏改为从按下“历史”计(原从进程启动计,含打开仓库)。R1 后基准更严:PERF-03、04 到视图收下的第一块图块(`lok: tile (x, y) shown`),其间有图块画失败即整次作废;DOCX 样本原以单独一段的分页符分页,内核只排出 60 余页(`Heading1` 段上的 `pageBreakBefore` 同样丢页),改为分页符放在标题段内,PERF-03 之前先核对恰为 100 页(视图记录页数);PERF-02 每键须有自己的计时,缺即失败;PERF-08 循环前记基线,记内核中的文档数(worker 每次载入关闭写 `N documents open`),文档数须回到基线,文件数自第一次之后不变(第一次打开模板对话框时进程一次性载入两个系统字体与两个 AppKit 本地化表)。worker 的启动核对失败后保持失败,此后一切请求被拒(原核对失败的状态与成功相同,第二次即放行)。空白文档就绪由约 2.5 秒降到约 1.0 秒,发生在本次重建内核之后,与 0039 无关(对比实测),原因未查明。
- 2026-10-08:打印输出(C19.2,§19.3)。`ely_platform::print::print_pdf` 每次取系统打印设置的副本,纸张取 PDF 中最大的一页的尺寸与方向(一次打印只有一种纸张,较小的页按原大印在其上),四边距为零(文档的页边距已在 PDF 里),缩放为 1;`ELY_PRINT_TO=路径` 让作业写成文件、不弹面板(测试用);原先用共用设置,打印机默认纸张与边距使 A4 以外的文档被裁切或偏移。Doc、Table、Show、PDF、Mark 的打印都先等输入落地、在后台写出同一份 PDF(同一修订、页面设置与缩放),对话框给出页数与范围,再交系统面板(`ely_office::print_later`);取消不动文档,副本随即删去。`tests/scripts/print.sh` 真窗口核对。
- 2026-10-08:兼容清单与往返验证(C19.1,§19.1、§19.2)。`docs/compatibility.md` 是随版本发布的兼容清单(版本、内核与补丁、验证机,各应用的编辑保存、导入受限与输出,已验证与限制),版本或格式行为变了即同步改它。往返用例在 `crates/ely-lok/src/client/tests/independent.rs`:Ely 编辑、保存、关闭、再打开、再编辑、再保存,再由 `tests/scripts/ooxml.py` 检查;它不用 LibreOffice,自己解 OOXML,预期值自己算(整数、日期序号、错误值、文本严格相等,浮点按用例声明的容差),DOCX 另交 macOS 的 `textutil`;没有 python3 时报 SKIP。与 Microsoft Office 的交叉检查未做(本机没有 Office)。
- 2026-10-08:恢复副本、引擎停止与自动保存(C06.5,§6.2、§6.3)。Doc、Table、Show、PDF 共用 `ely_office::Recovering`:每 10 秒为有改动、已落定、不在导出的标签经 `Engine::snapshot`(写副本,文档不动)写一个恢复点(`ely_files::Recovery`,限额取存储设置;加密文档封存),索引在数据目录 `recovery/index.json`;崩溃标记照 ElyMark 的 `Crash`,下次启动把崩溃留下的最新点开成未命名副本并标为未保存,提示写明只恢复了内容,光标与撤销历史没有。引擎进程退出(`Engine::ended`,客户端 `try_wait`)时 `ely_office::Stopping` 在上方显示提示条(原因、最近一次恢复点的时间)与“重新打开文档”:`Engine::restart` 换上新 worker(LOK 照原配置与作者名重起,PDF 下次打开再起),有改动的标签从最新点开成副本,无改动的从文件重开,有改动却没有恢复点的列出;被放下的标签迟到的失败不再提示,指向它们的关闭询问一并撤下。重开的副本接过原恢复点的文件夹(封存随之),旧点留到新标签写下新点为止;载入或解封失败的条目留在索引里,下次崩溃后的启动再给。需要密码的副本(加密 PDF 的恢复点照原加密写出)照样询问,答后仍是未命名副本(`Source::Locked`)。引擎是否退出在出错时与每次恢复计时时查,空闲的引擎退出也会显示。恢复索引经 `ely_files::save_atomic` 暂存后替换,写不进时上一份照旧可读;写不进即提示一次、每次计时重试。干净结束后的下次启动只删已接过或正常的条目,没能重开的留着再试。自动保存(设置“文档”页,每个应用一个开关,存在应用的 `settings.json` 的 `autosave`,默认关):只对本应用自有格式(`Kind::save_as`)、无风险(宏、签名、修复)、状态为“已编辑”的标签,编辑计数停 2 秒不变即走普通保存;保存失败、外部改动后状态不再是“已编辑”,不再自动写,等用户处理。Cmd+W 关闭标签是共用命令(`ely_ui::commands::CloseTab`,原来只有 ElyPDF 绑了;文档应用里 Cmd+W 落进 LOK,内核按自己的 Ctrl+W 关掉框架,worker 下一次绘制或读取即在已释放的文档上段错误);worker 另拒绝内核关闭框架与退出的快捷键(Mod1 加 W、F4、Q)。真窗口(macOS):`recovery.sh`(崩溃后副本回来、关闭询问;只杀引擎后提示条、重新打开、文字回来、关闭询问),`autosave.sh`(关时不写,开关存下,开后停手即写,外部改动后不覆盖)。未验证:Table、Show、PDF 的真窗口恢复(同一外壳);Windows 与 Linux。
- 2026-10-08(用户定):首版发布六个应用(ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark);ElyMail 与 ElyAgent 第二批发布。ElyMark(C14)做完即开始首版的完整测试与打包,C12、C15 留到第二批。
- 2026-10-08:数据管理(C18.3,§18.3)。设置的“存储”页(每个应用):本应用数据目录里的日志(`ely.log`)、网页内容缓存(`web`,只 ElyMark 显示)、恢复副本(`recovery`)各自的大小与清除(`ely_platform::storage`);日志当场清空(日志器以追加打开,截短不坏句柄),缓存与恢复副本在用着,写下记号(`.clear-web`、`.clear-recovery`),下次启动在设置与窗口之前删去(删不掉即启动失败、写明原因,记号留着下次再试,不打开没删掉的数据);每次清除先问,写明删什么、何时删;每个文档保留的恢复点(10、20、50)与空间(32、64、256 MB)存在应用设置的 `recovery`(严格读,不合即报错),ElyMark 写恢复点时照用(C06.5 的文档应用同)。日志超过 8 MB 时,启动时只留后 4 MB、从整行起。数据库的升级前备份与失败只读只属 ElyMail(第二批),首版的六个应用没有数据库;邮件与 Agent 历史导出同属第二批。组件库 ea4ed4c:`SettingsRow` 是以标题命名、以说明描述的组(原先行标题不进 AX 树,两个同名按钮分不出);待清除的状态以禁用的按钮写明。
- 2026-10-08(用户定:图标照 Papirus 重画):每个应用是 Papirus 192 网格上的一个大的扁平物件(页、讲台、信封、终端),主体一种饱和色,折角为其浅色,内容为白色、至多一个点缀色,主体与内容带 Papirus 的柔和投影(下移 4 或 2、模糊、20% 黑)。macOS 为浅色 squircle 上的同一物件(占主体 86%),Linux 与 Windows 只有物件。48 像素以下用无投影、无小细节的简化版。母版由 `packaging/icons/masters.py` 写出,配色见 `assets/icons/README.md`。Papirus 图标包(`assets/papirus_icons-master.zip`,用户放入)只作参照,不随包。
- 2026-10-08(用户要求核对各系统的文件关联):文件类型只写在 `packaging/types.tsv`(应用、扩展名、MIME;`/` 为文件夹)。Linux 的桌面项由它写 `MimeType=`(原先没有,桌面环境从不提供 Ely 打开文件);Windows 安装程序由 `packaging/windows/types.sh` 生成的 NSIS 宏注册(原先没有):HKCU 下每个扩展名的 `OpenWithProgids` 与 `Applications\<程序>\SupportedTypes`,ProgID `ElyOffice.<名称>` 带图标与打开命令,ElyTree 在文件夹右键菜单加“Open in ElyTree”;只进“打开方式”,从不改默认(同 macOS 的 Alternate),卸载时删去。makensis 在本机编过 ElyDoc 与 ElyTree 的安装程序;Windows 与 Linux 上实际打开文件未验证。
- 2026-10-08(用户定:窗口缩窄时控件挤成一团、越出边框,不可接受):窄窗口的版面。外壳一次排两侧栏(`shell/panel.rs` 的 `room`,有单测):内容区至少留 360,放不下时两侧先缩到各自最窄,再先藏右栏、后藏左栏,窗口变宽即回来,用户拖的宽度不变。组件库 6d95b24 至 69265b4:`ScrollArea::vertical` 只纵向滚动、内容不得宽于区域(侧栏一律用它,原先横向不受限,整栏按自然宽度排后被裁);`PropertyGrid` 每行可折行,放不下时控件到标签下方,标签列 104;全部图标的 `SegmentedControl` 与 `Tabs` 放不下全部文字时只显示图标,文字作提示与无障碍名(宽度取上一帧量得的条宽与文字排版宽度);`StatusBar` 两侧与各项可缩,文字以省略号结尾;`Banner` 文字折行;`Button` 不再拒绝收缩,只在无法折行时缩并以省略号结尾;未聚焦的单行文本框显示文字开头(原插入点在末尾,看到的是尾部);`Switch::name` 只给无障碍名(行已有标签时不再重复画字);`CommitInput` 的勾选与按钮一行可折。各应用固定宽度的控件框(`w_32` 等)改为占满、以原宽为上限。`tests/scripts/layout.sh`:六个应用各开样本,640、800、1100 宽下读 AX 树,任何元素越出窗口左右即失败,并留截图供目视(栏内裁切 AX 看不出)。缩放(用户问为何 Doc、Table、Show 不能缩放;视图早有 `set_zoom`,界面从未接上):`ely_office::Zooming`,Cmd+= / Cmd+- / Cmd+0,“视图”菜单三项,状态栏右侧 `ZoomControl` 的属主模式(组件 69265b4:显示属主给的百分比、按下交属主;原只缩放整个界面);Cmd+滚轮与触控板捏合以指针处为中心缩放(`view/zoom.rs`,档位 25% 至 400%);ElyShow 的 Cmd+0 回到适合幻灯片,手动缩放即停止适合。缩放只在内存里随标签,不跨重启。
- 2026-10-08(用户定:侧栏太占地方,要可调):侧栏宽度(C05.6)。外壳的左右侧栏(`shell/panel.rs`,八个应用共用)默认左 220、右 320(原取 `sidebar_width` 240 与 `sheet_size` 380;右 320 是 ElyDoc 属性栏不截断控件的最窄宽度,实测 280 截断数值框);内侧边缘是组件库的 `layout::resize_edge`,拖动改宽,左至少 160、右至少 280(原 220,属性栏放不下)、至多窗口的 45%;拖动停 0.5 秒后存入应用设置的 `panels`(`settings::panels`),退出时还没存的当场写入(窗口位置同),启动时严格读回(不是正数即在设置窗口写明并用默认),写入失败也在设置窗口写明;开合动画期间不显示拖动边。
- 2026-10-08:凭据与私密内容(C18.2,§18.2)。恢复密钥(加密文档的恢复副本用)存在系统凭据库(keyring:钥匙串、Windows 凭据、Secret Service);凭据库不可用或拒绝时改用本次运行的随机密钥,只在内存(`RecoveryKey { key, lasting }`,`lasting` 为假即提示恢复不跨重启),从不写成普通文件;用旧密钥封存的恢复点读不开即报“无法恢复”,不导出明文。PDF 打开密码与证书密码只在 worker 内存里、文档关闭即弃。日志:worker 的 stderr 经 `ely_ipc::relay` 按其级别并入应用日志(原先从访达启动时 worker 的日志丢失),两个 worker 与应用一样读 `ELY_LOG`、默认 info(LOK worker 原读 `RUST_LOG`,平时只出错误);日志调用不打印正文、密码、密钥;引擎(worker、内核、MuPDF)给的错误串可能引用文档,日志只记请求号与错误类别,全文只经回复交给界面;worker 的 stderr 只放行其结构化日志记录与崩溃栈帧,引擎自己打印的文字不进日志(只计数)。worker 启动失败也写成结构化记录,最后一条错误由 `Relay::last_error` 带进客户端的报错。诊断导出时姓名来源(账户名、设置)读不出即不导出并写明原因;日志尾部从整行开始。诊断(设置里的“诊断”,每个应用):先列出要导出的内容(本应用日志的最新 4 MB、macOS 上 7 天内本应用与其 worker 的崩溃报告,列表在 AX 树里),开关“去掉路径与个人姓名”默认开(凡路径与 URL 整段换成 `<path>`:有引号到引号为止,否则到行末;分段标题只写文件名;账户全名、登录名与作者名按从长到短换成 `<user>`),导出为一个文本文件(`ely_platform::diagnostics`),不上传。Git 服务的凭据照用用户自己的 credential helper 与 ssh-agent;打开文件不发任何请求。未验证:Windows 与 Linux 的凭据库与崩溃报告位置(那两端诊断只含日志)。
- 2026-10-08:配置(C18.1,§18.1)。通用偏好在 `<本机数据目录>/ElyOffice/settings.json`,各应用的在其数据目录的 `settings.json`;都经 `ely_platform::settings::Store`:带 `version`(本构建为 1;更新的版本或读不通即报错,不覆盖),写入在同目录的 `.settings.lock`(`ely_platform::lock::FileLock`,flock 或 LockFileEx,由 ely-files 移来共用)下先重读、只改所写的键、未知字段原样留下,临时文件 fsync 后原子替换;多个应用同时写不丢字段(单测八路并发)。通用字段(`settings::Shared`,类型不对即报错):外观(跟随系统、浅色、深色)、语言(跟随系统、English、简体中文;下次启动生效,设置里写明)、动效(跟随系统、减少、完整)、跟随 Omarchy(只在有 Omarchy 时显示)、作者名(空即账户名;`user::name` 先取它,ElyPDF 的批注、LOK 的批注与修订都用它)。环境变量 `ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION`、`ELY_LOCALE` 仍优先。`ely_ui::settings` 启动时读入(读不出即用默认值、记下原因),以 `notify` 监视文件夹,别的应用写入后即刻重读:外观、Omarchy、动效当场换,作者名经 `observe_global` 在后台交给 LOK worker(`set_author`)。设置窗口(应用菜单“设置…”,Cmd+,,每个应用一个)用组件库 `SettingsLayout`、`SettingsSection`、`SettingsRow`、`ThemeSelector`(组件 489ef31 起三项文字进内建中英目录)、`SegmentedControl`、`Switch`、`forms::Input`:通用;Doc、Table、Show、PDF 另有“文档”(作者名,失焦或回车写入);ElyTree 另有“Git”(Git 程序,空即随带的;写入前在后台核对 2.40 或更新,不是即在框下以 Banner 说明、不写;`ely_platform::git::locate` 次序为 `ELY_GIT`、此设置、随带的,版本核对移入 `ely_platform::git::system_version`,下次启动 ElyTree 生效)。文件读不出时窗口上方以 Banner 写明原因,改动不保存。作者名与 Git 程序每次提交都编号,写入经 `Store::set_if` 在文件锁下核对仍是最新的提交才落盘(等锁期间改了主意,旧写入作废);写入失败另以 Banner 写明,键入的值留着可重试;作者框在未编辑时跟随别的应用写入的名字。版本核对先看 `--version` 的退出状态(`git::version_of`)。各应用主窗口的位置、大小与最大化、全屏存在应用的设置里(`window`,停止移动 0.5 秒后写),启动时所记之处的中心仍在某块显示器上才用。另:ElyMark(CEF)直接启动的应用继承了 Chromium 忽略或屏蔽的 SIGTERM,测试的 pkill 杀不掉、留下进程(实测数个钟头的残留);`apps::open` 在 exec 前把 SIGTERM、SIGINT、SIGHUP、SIGQUIT 复原并清空信号屏蔽(单测:启动者忽略并屏蔽 SIGTERM 时子进程照样被终止)。未验证:Windows 与 Linux 的锁与窗口位置。
- 2026-10-08:进程与状态核对(C17.1,§17.2、§17.4)。帧(`ely-ipc`)由长度、标签、请求号与内容组成:客户端每个请求一个递增的请求号,worker 的回复带回同一个,不符即报错(`check_reply`;原先只靠一问一答的次序配对,丢一个回复即整串错位而不自知);LOK 主动推送的事件请求号为 0(`UNASKED`);PDF worker 的版本戳包含 `ely-ipc` 的源码。帧超过上限(64 MB)发送前即报错,什么也不写;worker 的结果超限时回一个错误,worker 不退出。LOK 的输出读不下去(坏帧、提前结束)时,原因经回复通道交给等待的请求,客户端停下并回收 worker 再报出(原先请求一直等进程结束)。会话:LOK 每次载入一个会话号(递增,原先用内核文档指针,释放后的地址被下一份文档复用,旧句柄读到新文档、事件被当作已关的丢掉);PDF 的 `Doc` 带打开它的 worker 会话,客户端发送前核对,别的 worker 的旧句柄被拒;worker 的文档号带进程前缀(进程号与启动时刻),别的进程的号在此不存在。修订(PDF):`Doc` 记着读到时的历史标记(标记相同即文档相同,撤销回到原标记),指向页、对象、位置的请求带上它,worker 在动内核之前核对,不符即以 `stale:` 拒绝;整份文档的请求(页数、保存、撤销、元数据、附件列表与添加、保护等)带“任意”;按键读取或移除附件带标记。ElyPDF 的编辑在排队时记下当时的句柄,落地后按新标记换句柄,过期的读取照旧按版本丢弃(画不出的块不因过期记为失败),过期的编辑提示“文档已改动,未执行”:排在别的编辑后面的编辑因此被拒(它对着的是前一个编辑落地之前的页面),再按一次即可。LOK 的修订(R2):worker 给每份文档记一个修订,会改文档的请求(键、鼠标按下与松开、拖动、文字、命令、粘贴、剪切、函数补全;不按键的鼠标移动不算)在执行前加一;按编号指向工作表、幻灯片、对象、参考线、制表位、行列的命令(参数里有 `Index`、`nTabNumber`、`TabNo`、`Part`、`Row`、`Col`、`Column` 或区域的四角,值不是 -1;-1 是新建)先发 `EXPECT` 带读到时的修订,不符即以 `stale:` 拒绝,内核不动;同一次读取得出的一组命令(对象列表的多选)只核对一次,在一次 worker 调用里依次执行(`Worker::run_all`、`LokView::run_all_at`、`Commands::run_all_in`)。`LokView::run` 拒绝带编号的命令,须经 `run_at`;外壳的 `run_in` 对带编号的命令用 `Host::read_at`(ElyTable、ElyShow 为其读取时同一次 worker 调用里取的 `Worker::now`,ElyDoc 为视图所见:推送的事件批次带修订,每个输入落地后视图另取一次修订);`Tabs::read_in` 也给读取闭包这个句柄(ElyPDF 为视图当前的句柄)。ElyTable、ElyShow 在视图所见修订新于其读取时重读,所以键入之后按编号的命令不会一直过期;记着编号的对话框(删除与重命名工作表、删除重复项、幻灯片备注栏键入期间)在读取时记下修订,确认按它执行(`Commands::run_on`),其间文档结构变了即拒绝;ElyDoc 属性栏按视图所见的修订发(删除所示的制表位)。输入失败时视图照样取回修订(请求在执行前已计数),之后的编号命令不会一直过期。过期的拒绝提示“文档在此间已改动,请重试”(`office.error.changed`)。MuPDF 打开中途(加锁检查、日志、修复)失败即释放已打开的文档与修复副本(原先每次失败留下整个文档;MuPDF 的存储另有 256 MB 上限的缓存,满后不再增长)。FFI:MuPDF 每个调用在垫片的 `fz_try`/`fz_catch` 里,返回码加错误信息;LibreOffice 只经 LOK 的 C 接口;worker 崩溃时客户端报出退出状态。工作进程:LOK 随应用窗口启动(启动即开空白文档),MuPDF 在打开第一个 PDF 时启动;不建进程池。后台:Git、OCR、缩略图、文件夹搜索、导出与系统打开都在后台任务里。不建设 §17.4 所列设施(无 RPC 服务发现、无常驻服务、Git 只有一个内核、Markdown 只有 Milkdown 与 CodeMirror)。
- 2026-10-08:应用之间的连接(C16.1,§16;首版只做六个应用之间的三项,邮件与 Agent 的三项随第二批)。启动别的 Ely 应用(`ely_platform::apps`):先找与本应用同一安装或构建的同级(`.app` 旁、Windows 的 `Ely Office\名称`、开发构建同一目录),再找 macOS 登记过的(按包标识 `AppId::bundle_id`,`NSWorkspace`);`.app` 一律经 `open -a` 交给 Launch Services,正在运行的应用经其打开请求收下文件(不另起一个),其余直接启动。应用切换:每个应用的应用菜单有“打开 Ely 应用”子菜单,只列已安装的其他 Ely 应用(`commands::switch_to`,八个无参数动作),启动失败即提示原因。导出后查看:导出、提取、拆分、删除标记内容、清理、保护、签名与附件另存成功后,提示带“打开”:PDF 在已安装的 ElyPDF 打开(作为新标签,不当作原编辑会话),否则与其他文件、文件夹一样交系统默认程序(gpui `open_with_system`);ElyPDF 打开失败即提示原因,不改交系统。ElyMark 导出缺图、图或公式时的警告同样带“打开”。Markdown 查看版本:ElyMark 状态栏的 Git 项把文件本身交给 ElyTree,ElyTree 收到文件即打开其所在仓库并显示该文件的历史(`Tree::open_path` 按文件所在文件夹开仓库,再以仓库内路径 `file_log`);不自动提交,不建仓库;未安装 ElyTree 时提示从发行包安装。真窗口(macOS):`mark-output.sh` 核对 ElyTree 打开该文件的历史、导出警告的“打开”在 ElyPDF 打开 PDF;`release-macos.sh` 从 ElyDoc 的应用菜单启动 ElyMark。AppKit 自己的窗口恢复关掉(`startup::no_window_restore`,`ApplePersistenceIgnoreState`):Ely 自己恢复会话;原先应用被强退后再启动,AppKit 先弹模态“是否重新打开窗口”,访达交来的文件被挡在后面(打包测试实测)。窗口测试的 `seen` 不看系统菜单(苹果菜单的“最近使用的项目”会列出别处打开过的同名文件)。未验证:Windows 与 Linux 的同级查找与系统打开;系统默认程序打开(会在本机弹出别的应用,未自动测)。
- 2026-10-08:ElyMark 的单行 `$$…$$`(用户以 Pandoc 示例发现):remark-math 把它读成行内公式,序列化只写一个 `$`,与原文不同,整段退成原文块。现按原文记下每个行内公式的 `$` 数(`math.js`),两个及以上以显示模式排出(同 GitHub),写回照原数;导出同样以显示模式。Pandoc 专有的表格、定义列表、行块不是 CommonMark/GFM,照 §14.3 以原文块保留(`---- ----` 在 CommonMark 里是分隔线)。
- 2026-10-08:ElyMark 书写视图、输出、Git 配合、大文件(C14.7,MARK-09、11、13、14,PERF-07)。书写视图:“视图”菜单的专注模式(⇧⌘F,收起标签条、左栏与状态栏;可视模式里光标所在的顶层块之外淡到 0.35,源码里当前行之外同),打字机滚动(插入点所在行保持在视口中部;上下各留半屏,首尾行也能居中),正文宽度窄、中、宽(36、48、64rem),字体无衬线、衬线、等宽,文字大小(⌘= ⌘- ⌘0,70% 至 200%,每档 10%)。全部在页面里以 CSS 实现(`look.js`:根元素的 data 属性与 `--ely-text-scale`),Markdown 不变;设置存在数据目录的 `look.json`(读不出即报错并用默认值),菜单随之打勾,启动时恢复;淡入淡出遵守系统的减少动效。组件库的 `ZenMode` 不用(见缺口)。输出(“文件›导出…”⌘E 一个对话框选 HTML、HTML 与资源文件夹、PDF、纯文本;“打印…”⌘P):页面以 unified(remark-parse、remark-gfm、remark-math、remark-rehype、rehype-katex、rehype-stringify,版本写死)把编辑器此刻的全文(与懒挂载、大文件无关,不截断)渲染成 HTML,Mermaid 用浅色主题画成 SVG(画不出的照原代码块并在导出后提示);front matter 不进输出。HTML 内嵌导出样式表(构建时单独产出 `export.css`:KaTeX 样式与 woff2 字体内嵌、浅色排版)与图片(文档相对路径、百分号转义、file URL 读成 data URL;网址照原样;读不到的图片照原链接并提示);HTML 与资源文件夹写 `名称.html` 与 `名称_files/`(样式表与图片副本,同名图片按序号区分;文件夹已在即拒绝,写坏即删去);纯文本以 hast-util-to-text 按块换行,公式写 TeX;PDF 先把同一份内嵌 HTML 暂存到工作目录,在不显示的 CEF 视图里由 Chromium 打印(`WebView::print_pdf`,结束经 `Event::Printed` 报回),再交到所选位置;打印同一份 PDF 交系统打印面板(`ely_office::print_later`)。导出都经 `ely_files::export`(不覆盖打开着的文件)。富文本复制用 Chromium 自己的复制(剪贴板带 HTML)。Git(MARK-13,不做第二套客户端):Git 的查找与运行环境移入 `ely_platform::git`(`locate`:`ELY_GIT`,应用旁,ElyTree 包内的 Git,本构建的 `engines/git`;`command`:只读不抢锁等同一套环境变量),ElyTree 照用;ElyMark 在后台对当前文件问 `rev-parse --show-toplevel` 与 `status --porcelain=v2 --ignored`,状态栏显示未改动、有改动、未跟踪、已忽略、有冲突(问不出即“未知”,提示里是原因;不在仓库里不显示),按一下以仓库根打开 ElyTree(`ely_platform::apps::open`);切换标签、每次保存(指纹变化)、外部改动与窗口回到前台时重问。大文件(MARK-14):超过 2 MiB 的文本以源码打开,上方提示条写明大小与“可视与预览已关闭,保存保留全部内容”,切到可视或预览即提示不切;保存与导出都是全文。PERF-07 实测(macOS,release):1 MB 文档自启动到可视可编辑 1.4 秒(首次冷启动 2.0 秒);4 MB 在可视下约 5 秒,故按源码打开。真窗口(macOS,`tests/scripts/mark-output.sh`):Git 状态未改动→键入自动保存后有改动→按下打开 ElyTree 于该仓库;专注模式收起标签、存入 `look.json`、重启仍在,文件不变;四种导出的内容(KaTeX、SVG、内嵌图片、全文;资源文件夹的样式与图片;PDF 以 pdftotext 读出文字;纯文本含 TeX);打印生成 PDF;3.3 MB 文件以源码打开、⌘1 不切、末尾编辑后全文保存、纯文本导出含最后一行;`ELY_CLIPBOARD_TEST=1` 时可视全选复制带 HTML。`mark-content.sh` 的 look 步核对淡出、窄列、衬线与插入点居中。去掉专注收起、图片内嵌、大文件阈值、Git 状态或截断文本导出时各自失败。窗口测试须在英文输入法下运行(中文输入法会把模拟键入收进组字)。导出时每张图定为一个 `src`(去掉 `srcset`、`sizes` 与 `picture` 的 `source`,无 `src` 时取 `srcset` 的首个地址);公式的 KaTeX 错误随导出提示(行号与 KaTeX 的说明),不当作完整导出。引用正文的提示走外壳的 `toast_unlogged`,日志只记计数与行号;组件库 28a6bba 起 `Toaster` 只记提示的编号与级别,不记标题。未验证:Windows 与 Linux;Windows 的 Git 路径在 ElyTree 包内的位置。
- 2026-10-07:ElyMark 外部变更、自动保存与会话恢复(C14.6,MARK-08、12,§6.2、§6.3、§14.4,QA-22)。监视:一个 `notify` 监视器盖住各标签文件所在的文件夹(不递归),200 毫秒内的一串改动合成一次查看;按会话指纹(长度与修改时间)比对,自己的保存不触发。文件改了:读到稳定的字节(读前读后指纹相同,最多五次),无未保存内容(含页面还没送回的编辑)即原地换成磁盘的文字(`ely.refresh`:插入点所在的文件行与滚动尽量保持,时间线从这份文字重新开始),会话重载指纹并以新文件身份登记;有未保存内容即不动磁盘与编辑器,状态为“外部已改”,自动保存停止,上方提示条“比较…”。比较:组件库 `git::regions` 对“上次载入或保存时”的文字、你的与磁盘上的三方合并,`ThreeWayMerge` 逐个冲突选边(编辑器的文字先落定;比较期间磁盘再改即重读重比,选定前核对所读内容的指纹,不符即提示再比较一次、不写入),可“用磁盘上的”“保留你的”(立即覆盖写入)或冲突都选完后“用合并的”(磁盘的版本成为新的基底,合并结果作为一步可撤销的编辑,随后自动保存)。路径不见了:等 300 毫秒(原子替换的空档)仍不在,即在原文件夹里按文件身份(设备与 inode,不认符号链接)找改名后的文件(`Sessions::renamed_in`),找到即标签跟过去(与 F2 改名同一条路);找不到即为已删:内容留着,提示条“另存为…”,不再自动保存、不重建文件。读不出(权限等)或监视器出错:提示条写明原因与“再查一次”;“视图›检查磁盘上的文件”随时手动查看(网络目录等不保证有事件)。自动保存(§6.2):有路径的文件停手 1 秒后保存(`ely_files::PolicyEngine`),外部改动或删除后停止,选定处理后重新开始;失败照常留为未保存。会话(MARK-12):数据目录的 `session.json` 记各标签的路径(未命名为空)、名称、模式、插入点所在的文件行与滚动;每 10 秒有未保存内容且有变化的标签写一个恢复点(`ely_files::Recovery`,每标签一个文件夹,20 点 7 天);退出(Cmd+Q 或关窗)开始时先记下当时的标签;`session.lock` 写进程号,正常退出删去它与全部恢复点;下次启动锁还在且进程已不在即为崩溃(`ely_files::alive`)。启动时打开记下的文件,标签显示后换成记下的模式与位置;崩溃后的未保存内容从最新恢复点放回,一律开成未命名副本(有路径的文件照磁盘打开,从不先覆盖原件,§6.3;原件已不在也照样开副本并提示哪个文件没能打开)、标为未保存,并提示“恢复了某时未保存的内容;撤销历史未恢复”(§6.3 区分内容与历史)。显式“不保存”后正常退出,下次不当作崩溃,未命名的内容不回来。真窗口(macOS,`tests/scripts/mark-outside.sh`):原子替换后干净标签换成新文字、键入后自动保存;未保存时外部写入不被覆盖,比较三栏、保留你的写回;互不冲突的两处改动合并后都在;外部改名后标签名跟随、保存写到新名、旧名不重现;改成无权限读即提示、恢复权限后“再查一次”重载;删除后不再自动保存、不重建;退出后重开回到源码模式与原来的行;未命名文档写过恢复点后 `kill -9`,重开内容回来且关闭时问是否保存,选不保存后再开不回来。去掉干净重载、改名跟随、删除时停自动保存、读错提示、崩溃内容、退出时记下标签或恢复的位置时各自失败。启动时先显示可视再换到记下的模式(一次多余的绘制,已知)。启动参数给了文件时只开它们与崩溃恢复的副本,不连带上次的标签。“保留你的”以所比较的指纹为基线(`Session::rebase`)走普通保存,之后的外部改动照样冲突;自动保存撞上外部改动时交给比较(`Host::conflicted`),不弹外壳的冲突框;外壳在询问取消、保存面板取消、保存失败、冲突询问取消时都调 `Host::close_cancelled`,关闭标签时调 `Host::tab_closed`(ElyMark 即刻删其恢复点并更新清单);每个标签只在首次显示时恢复一次;缺列的位置读作 0。组件库 f756043:脏标签与非当前标签的关闭按钮透明而非隐藏,辅助技术可按。外壳冲突询问的“保留我的”以此刻磁盘的指纹为基线走普通保存(导出期间再改照样冲突;文件已不在时直接写),“从磁盘重新载入”先结束挂起的关闭;退出途中首次保存的标签以新路径记入清单。取消退出(未保存询问点取消)后清单照常跟随(`Host::clLine truncated
- 2026-10-07:ElyMark 编辑工具与附件(C14.5,MARK-06、07)。查找替换(Cmd+F):组件库 `FindWidget` 盖在页面右上,查询与替换是两个 `TextInput`,带区分大小写、全词、正则三个开关;可视用 `prosemirror-search`,源码用 `@codemirror/search`;总数与当前序号由页面按不重叠的命中报回;正则写错时框内显示 JS 的原话(按两个编辑器所用的 `gmu` 标志编译);空的或无效的查询不执行查找命令(CodeMirror 对它会打开自带的面板);零宽命中按码点前进(落在代理对中间时 Unicode 正则会往回答,原先反复收集同一处直至页面崩溃);预览不替换;换模式或换标签后按同一查询重查。查找框整窗一个,各标签的页面各记其查询:下一处、替换每次都带上框里此刻的查询与替换文字,标签一换即按框里的重查。查询变了从当前命中的起点往后找,键入替换文字不移动命中。替换框回车替换一处。缩进:源码 Tab 与 Shift-Tab 为 `indentWithTab`;可视在列表里 Tab 下沉、Shift-Tab 上提,不在列表时吞掉;代码块同源码。WebView 在自己的键位上下文绑定 Tab 与 Shift-Tab 交给页面(组件库 K02 起更深的上下文先得),焦点不离开编辑器。列表续写:可视为 Milkdown 自带,源码为 lang-markdown 的回车续写。“表格”菜单:插入表格(3×3)、上下加行、左右加列、删行、删列(prosemirror-tables);插入后插入点落在新表第一格(Milkdown 在段末插入时把插入点留在段里,之后的行列操作落空);插入点不在表格里、或在源码与预览里即提示。代码高亮见 C14.3。附件:拖放图片文件或粘贴剪贴板里的 PNG,复制到文档旁的 `assets/`:拖放保留原名、重名加序号,粘贴名为 `pasted-毫秒.png`;都以 `create_new` 占名,从不覆盖已有文件,写坏即删去半成品(`ely_files::import_attachment` 原先先查后拷,其间出现的文件会被覆盖);在插入点写入相对链接,名称按百分号转义,源码里替代文字的方括号与反斜杠加转义;复制期间文档另存到别的文件夹即不写链接,提示图片留在原文件夹的 assets;未命名文档、预览与非图片文件拒绝并说明。粘贴经 `WebView::intercept_paste`:宿主先看剪贴板,图片自己处理,其余照常交给页面。macOS:CEF 离屏输入不带 AppKit 编辑命令,而 Blink 在 macOS 上只凭这些命令处理 Cmd+方向键与 Cmd+Backspace(Option 组合 Blink 自己处理),可视编辑器里这些键原来不动;`ely-web-content` 的键盘处理器(`edit.rs`)在页面没处理这些键时照 AppKit 标准键位以 `Selection.modify` 移动或扩选到行首行尾、文首文末,Cmd+Backspace 与 Cmd+Delete 删到行首、行尾;CodeMirror 自己处理的键不经此。组件库 8f4729c 与 aa0c99d:开关按钮(`ToggleButton`、`ToggleGroup` 的各项)在 AX 树里是按钮,名为其文字或提示,带按下状态。真窗口(macOS,`tests/scripts/mark-tools.sh`):可视全部替换,源码正则替换,Tab 与 Shift-Tab,列表续写,Cmd+Down 后插入表格、加行加列、删列,拖放图片(自带的拖动源)复制到 assets 并写入转义的链接;`ELY_CLIPBOARD_TEST=1` 时粘贴图片同样落地;复制被拖住(FIFO)时经菜单另存到别处,不写链接;两个标签各设查询后回到前一个全部替换按框里的;键入替换文字后替换一处落在原命中;源码拖入 `a]b.png` 写成 `![a\]b](…)`。去掉 AppKit 键、正则、Tab 绑定、插入点修正、文件夹核对、起点查找、标签跟随或转义时各自失败。`tests/scripts/comments.py` 另查 Swift 测试助手与页面的 JS 源码。本机有全局快捷键占用 Cmd+Shift+S 与 Cmd+Shift+Z(空白 AppKit 窗口也收不到),所以测试经菜单另存。未验证:Windows 与 Linux。
- 2026-10-07:ElyMark 导航与搜索(C14.4,MARK-05、MARK-10,MARK-01 的打开文件夹)。左栏三页(分段控件):文件、大纲、搜索。打开(面板可选文件与文件夹,启动参数同)文件夹即在后台按其真实路径列出其下的 Markdown 文件(`folder.rs`:`.md`、`.markdown`,不进点开头的文件与文件夹,不跟随链接,只在所开的文件夹里,不扫整盘),以组件库 `lists::FileTree` 显示(只画可见行,可过滤),选中按回车或双击打开;窗口回到前台、改名之后即重列,列表变了即重搜文件夹。没开文件夹时文件页是最近列表(`ely_files::recent`:数据目录的 `recent.json`,新的在前,留 20 个,读不出即报错且不覆盖;ElyTree 的同一套移入此处共用),打开成功的文件与列出的文件夹记入,按真实路径(同一文件只一条);标签的路径变了(改名、另存为)即记入新路径,旧路径已不存在即移出。大纲:页面在显示后与每次送出全文时报出当前编辑器的标题(可视与预览取文档里的标题节点,源码取 CodeMirror 的语法树,ATX 与 Setext),以 `lists::Outline` 显示,原文块里的标题也在内(以 remark 读其源码,引用、列表中的标题同样列出,跳转落在标题文字上),选一项即把插入点放到该标题并把键盘焦点交给文档(预览滚到该处)。搜索:本文件或文件夹(打开了文件夹才有),按字面、不分大小写逐行找,打开着的文件按其当前文字(含未保存的),其余读盘,读不出或不是 UTF-8 的文件以“某文件未能搜索”的行列在结果最前(带原因),最多 500 条;本文件的结果随当前标签:切换标签即重搜,较早的搜索迟到即弃;结果列出行文字与“文件:行”,选中回车即打开(已开即转到,按真实路径认标签)并把插入点放到该行的那一处:源码定位到该行的命中,可视按写出时每个块在全文里的位置(`preserve.js` 的写出同时给出各块的区间)找到该行所在的块,再按块内此前命中的个数落到块中对应的命中(此前的行先经 Milkdown 解析,按编辑器所见的文字计数,链接地址等不算,与块内查找同按不重叠计;原文块照源码计数;该行所见文字没有查询词时——命中只在链接地址等处——落在该行开头);行号自文件开头算,可视与预览先减去 front matter 的行数,落在 front matter 里即滚到它。页面未显示完就要求的定位等显示后再做。多标签由外壳。真窗口(macOS,`tests/scripts/mark-places.sh`):文件树不列 .txt、点选回车打开,大纲跳到二级标题后键入落在标题里,文件夹搜索找到两个文件的行、不搜 .txt、选中结果打开并在该行键入,最近列表重启后还在、从中打开文件夹。组件库 dabc1eb 起树与树行有无障碍角色(树、树项,名称、层级、选中、展开);大纲项仍没有(测试按位置点选)。未验证:Windows 与 Linux。
- 2026-10-07:ElyMark 编辑模式、文件与语法(C14.3,MARK-01 至 04,§14.3)。模式:可视、源码、预览,“视图”菜单与 Cmd+1/2/3,状态栏右侧分段控件;每个标签各记其模式,页面回落到源码时标签随之。切换在页面里进行(`ely.mode`):先取当前编辑器的全文并立即送出待送的全文,再以同一文字显示新模式;每次切换都新建目标模式的编辑器(不再保留隐藏的编辑器)。本文件只有一条时间线:带着编辑离开一个模式时,把离开时的全文记入时间线;编辑器自己的撤销用尽(各看其撤销深度,Milkdown 的历史键位排在我们的之后)时再按撤销,回到时间线上一条全文、以当前模式重建(另一段的编辑作为一步撤销),重做用尽时向后走;只有真正的编辑会截掉时间线上其后的部分,撤销、重做的跳转不算编辑;离开时编辑器还有可重做的步骤,就把全部重做后的文字记在时间线前方,回来后重做即到那里。模式请求按序号只认最新的:编辑器构建期间再要的模式记下,构建完且仍是最新请求时显示它,此后的新请求清掉它;较旧的显示回报回来时序号不符即退出,不碰排队的请求。预览每次新建、只读(正文与代码块)。切换本身不改文件。文件:新建(空的未命名 `untitled.md`,保存走另存为)、打开、保存、另存为(外壳)、重命名(F2,“文件”菜单):`commands::RENAME` 与 `commands::RENAMING`(含重命名的文件命令表),`document_root`/`set_document_menus` 改收文件命令表;ElyMark 的 `rename.rs` 在原文件夹里改名(只有它用,不放外壳),`ely_files::rename_new` 不覆盖已有文件(macOS `renamex_np` 加 `RENAME_EXCL`,Linux `renameat2` 加 `RENAME_NOREPLACE`,Windows `MoveFileExW` 不带替换;后两者未构建验证),名称须不含斜杠且扩展名为可保存的格式,未命名或正在保存的标签不改名,改名完成时标签须仍开着且仍是原路径,否则只记日志、不动登记;同文件登记记下打开时的文件身份,原路径已不存在而身份相同即为同一文件(改名间隙里打开新名字会转到原标签),改名后以 `Sessions::track` 迁移登记(不关再开);外壳每次保存成功后也以它更新登记的路径与身份(原子保存换了文件);标签名与 `Session::renamed` 的路径随之,内容与指纹不变。文件 URL:Windows 的驱动器路径写成 `file:///C:/…`、UNC 写成 `file://主机/…`,去掉 `\\?\` 前缀,围栏反向换回 Windows 路径再取真实路径(字符串转换有单测,Windows 上未运行)。CEF 的请求处理移入 `fence.rs`(`handlers.rs` 原超过 500 行)。文档旁的图片:页面 `<base>` 设为文件所在文件夹,`WebView::allow_images` 让该文件夹只放行图片请求(资源类型为图片;导航、脚本仍只限内容文件夹),另存为或改名后随文件移动(已显示的相对图片不重载,已知限制);Mermaid 的包按脚本自身位置载入,不受 `<base>` 影响。Milkdown 7.22.2 的图片解析把无标题图片的 `null` 标题交给要求字符串的属性,ProseMirror 拒绝、Milkdown 记错误后丢掉整张图;`image.js` 改为空字符串。实测(macOS,`tests/scripts/mark-modes.sh`):文档旁的图片画出(截窗口量色),三种模式往返不改文件,预览不收键入,可视里键入后经预览回来撤销得回原样,源码里的改动回到可视并保存,改名到已有文件被拒、已有文件不动,改名到新名后文件移动、继续编辑保存到新文件,新建打开空的可视文档(读外壳写下的 `untitled.md` 须为空),可视键入、源码键入、回可视后两次撤销回到两者之前、重做回来(撤销跳转当作编辑、丢掉时间线时失败)(去掉预览只读、去掉保留编辑器、去掉旧编辑器的链接时测试失败);`mark-content.sh` 在同一段脚本里连要两种模式,最后显示后者(去掉记下的模式时失败);可视已显示、其回报未答时再要源码,最后为源码(不核对最新请求时失败)。“直接打开文件夹”随文件树在 C14.4。未验证:Windows 与 Linux。
- 2026-10-07:ElyMark 文件会话与原文保留(C14.2,§14.3,QA-21)。ElyMark 用 `ely-office` 的外壳(标签、打开、保存、另存为、关闭询问、冲突),引擎 `Mark`(`apps/ely-mark/src/engine.rs`):载入读字节,UTF-8 带不带 BOM 都可,UTF-16 与非 UTF-8 即报错并指出字节位置(`text.rs`);编辑器拿到去掉 BOM、CRLF 换成 LF 的文本;写出时文本与载入时相同即原样写回原字节,否则补回 BOM 并按载入时多数的换行(CRLF 多于单独 LF 即 CRLF;混用换行的文件编辑后统一成多数的那种,已知限制)。外壳的 `Engine::open` 改为收窗口、返回 `io::Result`(网页视图要窗口,页面开不了即为打开失败)。每个标签一个页面;页面每次改动立即报 `{"kind":"changed","version"}`,停手 300 毫秒后报该版本的全文 `{"kind":"text"}`;外壳的“已落定”即最新改动的全文已到,保存等它;历史标记为全文的长度与散列,撤销回原样即为未修改;外壳保存时先写出再读标记,两者之间页面可能送来新全文,所以写出时记下所写文字的标记,紧接的读标记交出它,之后的编辑仍算未保存。可视模式保留原文(`preserve.js`):照 Milkdown 自己的 remark 流程(`parse` 加 `runSync`)读出原文各顶层块的位置,与编辑器的顶层块一一对应(数目不同或有块无位置即不能对应;空文件 Milkdown 仍有一个空段落,照空处理)。每个顶层块单独序列化,与原文逐字相同的才是可视块,否则换成原文块(`ely_raw`:等宽、可编辑的纯文字,写出即其文字,不进撤销历史的一次替换);块间 Milkdown 不显示的原文(如链接定义:Milkdown 把引用链接内联、删去定义)也成原文块,所以没有藏起来的原文。序列化的标记照该文档自己多数的写法(`style.js`:无序与有序列表标记、强调、加粗、代码围栏、分隔线、Setext;以 remark-parse 统计后设 `remarkStringifyOptionsCtx`),写法一致的文档几乎全为可视块,与多数写法不同的块成原文块。写出时按块做 Myers 差异(`diff.js`;ProseMirror 未改的块是同一对象,否则按内容比较),未改的块与块间原文逐字照抄,只有改动的块用 Milkdown 序列化。YAML front matter 不进可视编辑器,以原文显示在上方,写出时原样接回。不能对应的文档直接以源码模式打开(可编辑,无损),并报 `fallback`。打开后插入点在文档开头。页面每次载入是一代(应用计数,随 `ely.show` 传入),回报带代数,别的代的回报丢弃;载入时版本计数归零,重载后显示最后收到的全文。故障视图按“重新载入”后焦点回到页面(原先留在消失的按钮上,键入无处可去)。普通 Markdown 读文件的权限错误不交外壳当作加密文件(外壳把 PermissionDenied 当作要密码),照系统原话报出;外壳打开失败另写日志。实测(macOS,`tests/scripts/mark-session.sh`):BOM、CRLF、front matter、Setext 标题、`1)` 列表、HTML 块、`~`删除线、硬换行、链接定义与 `[[...]]` 的文件,不改保存逐字节相同;在首段键入一字保存,除该段多一个字外逐字节相同(整篇序列化、去掉 BOM 时该测试失败);段落里的 `[[...]]`、引用里的 Setext 与 `[[...]]`、只有链接定义的文件都以原文块显示,键入一字只多一字;在链接定义前向前删除不移动、不修剪它;渲染进程被杀、按重新载入后再键入的字照样保存;无读权限的文件报“Permission denied”、不问密码(不用原文块、不按代丢弃回报时测试失败)。未验证:Windows 与 Linux;模式切换与撤销(C14.3)。
- 2026-10-07:ElyMark 内容包(C14.1,§14、§17.3)。网页源码在 `apps/ely-mark/web`:版本全部写死,`package-lock.json` 锁定;`scripts/build-mark-content.sh` 以 `npm ci --ignore-scripts` 安装、esbuild 打成经典脚本(file:// 下模块脚本被 Chromium 拦下)到 `engines/content/ely-mark`,附 `THIRD-PARTY-NOTICES.txt`(按打进包的每个包取其许可文件;没有许可文件的只许 MIT,以清单的作者与 MIT 原文写出,构建时列出)。包:Milkdown 7.22.2(commonmark、gfm 含脚注与任务、history、代码块组件)、CodeMirror 6(代码块与源码模式,语言取 language-data,全部打进包)、KaTeX 0.19.0(字体随包)、Mermaid 12.1.0(单独的 `diagram.js`,首次遇到图时才载入;`securityLevel: strict`,不在页面上留错误图)。数学按 Crepe 的做法(remark-math,块公式为 LaTeX 代码块)。只渲染文档里保存的源码;公式与图出错时显示错误(公式带位置,图带行号)与原文,不清空代码块。页面接口 `ely.show(markdown, mode, texts)`,mode 为 visual、source、preview;每次显示一个新容器与序号,较早的显示若后完成即销毁其编辑器、不报告,页面上始终只有最后一次的编辑器;预览时正文与代码块都只读;显示后经 `elyQuery` 报 `{"kind":"shown"}`。颜色全部取 `--ely-*`(CodeMirror 的高亮、Mermaid 的主题变量),随系统深浅色:Milkdown 把预览净化后拷进页面(不保留我们给的元素),且每次调用给的回调都是新函数,所以 Mermaid 预览先返回带编号的空占位,绘制结果只在该占位仍在页面上时经回调落下(改语言、改文字、卸载都换掉占位,旧结果即弃);绘制在全页一条队列里依次进行,每次绘制带代次,调用 Mermaid 前与落下前都核对属主、占位与代次,失效即跳过(编辑器销毁后排着的绘制不再执行);`:root` 的样式一变,凡占位仍在页面上的图(含正在绘制的)都以新颜色重画,此前的结果作废,首次落下的就是新颜色。记录归各编辑器所有,编辑器销毁即释放,占位已离开页面的记录在下一次绘制或换主题时清掉。Milkdown 把视口外的代码块换成占位(懒挂载),滚入视口时再建。界面文字由应用给(`mark.*`)。打包时 `bundle.sh` 把 `engines/content/<应用>` 放进 `Resources/content`,缺即失败;应用先找包内,再找本构建的 `engines/content`,都没有即报错。ElyMark 启动后显示一个空的可视文档(文件会话在 C14.2)。实测(macOS,`tests/scripts/mark-content.sh`):三种模式下标题、任务、表格、脚注、行内与块公式、代码块(逐块滚入视口,须可见、未被遮挡)、Mermaid 图(不小于 100×80)与两种错误,切换深浅色后图的颜色随之变,连续两次显示只留并只报后一次,截窗口背景色占比低于 0.97,KaTeX 字体已载入,无越界请求;`.app` 包内页面显示。未验证:Windows 与 Linux;树莓派上 2.5 MB 主脚本的载入时间。
- 2026-10-07:ElyTree 分支、标签、合并、变基、冲突(C13.4,TREE-07 至 09,§13.3)。Git 一律以 `GIT_EDITOR=true`、`GIT_SEQUENCE_EDITOR=true` 运行(没有终端可开编辑器,合并与撤销照 Git 自己拟的信息);失败时 stderr 为空即报 stdout(合并的冲突写在 stdout)。左栏第三页“分支”:本地分支(当前在前)、远程分支、标签(新的在前),`for-each-ref` 读出(附注标签取所指提交与标签自身的说明,`origin/HEAD` 不算分支,上游已删即不显示领先落后),上方“新建分支…”“新建标签…”;选中一个,中间列出提交、日期、上游与说明,以及适用的动作:切换(远程分支为 `switch --track refs/remotes/名`,不被同名标签劫走)、合并到当前分支、把当前分支变基到此、在此新建分支、重命名、删除(`branch -d`,Git 拒绝时报原话)、强制删除(`-D`)、删除标签。历史页提交详情多出拣选、撤销提交(合并提交以 `-m 1`)、在此新建分支、在此新建标签。新建分支可选“并切换过去”(`switch -c`);标签有说明即附注标签(`tag -a -m`,签名照用户的 `tag.gpgSign`)。变基、删除、强制删除、删除标签、中止、跳过每次都先问(每个确认只对这一次,§13.3),合并、拣选、撤销、继续不问。进行中的操作按 Git 目录里的 `rebase-merge`/`rebase-apply`、`MERGE_HEAD`、`CHERRY_PICK_HEAD`、`REVERT_HEAD` 判定(每次状态重读时读,只取最新一次),中间上方的提示写明正在把谁并到谁(变基时“当前侧”是新基底、“另一侧”是正在重放的提交),给继续(`<操作> --continue`)、跳过(合并无此项)、中止。命令失败时,只有合并、变基、拣选、撤销,且 Git 的输出有以 `CONFLICT (` 起头的行、失败后索引仍有未合并条目(`ls-files --unmerged`),才提示“因冲突停下”;其余(如钩子拒绝、冲突中 `checkout --ours` 的钩子失败)对话框显示 Git 的原话。失败的报错带上 stdout 与 stderr 两者(拣选的 CONFLICT 行在 stdout,钩子多写 stderr)。分支与标签的名称取自完整引用名(`refs/heads/`、`refs/remotes/`、`refs/tags/` 之后),同名的分支与标签各归各的;合并与变基用完整引用。分支行内的切换与删除接到同样的入口(删除照样先问)。冲突文件:中间上方是三侧与 `ThreeWayMerge` 的块级选择,各块照工作树文件里 Git 写下的标记分组(不另算一套合并;标记宽度取 `check-attr conflict-marker-size`,照 Git 的 `strtol_i` 读,不是正整数即 7;行须恰为该宽度,长短不同的同形行照正文;只按 LF 断行,同 Git,正文里的独立 CR 不算行界),基底取自 diff3 标记,没有时取 Git 自己 `merge-file --zdiff3 --marker-size` 对三个索引段的结果(块数相同时,只填没有基底的块,diff3 里空的基底照空);下方是可编辑的结果(`CodeEditor`,初为工作树里的带标记文件);选一侧即在结果里解决对应的那一块标记(其余手改照留),结果里的标记数与未选的冲突数对不上时(已手改过)不再按块选,提示在结果里改;“保存”只写工作树文件(经同一仓库的写队列,与 Git 的写操作不重叠;落地时按仓库与路径记下;路径已成符号链接即拒绝,不顺着链接写),“标为已解决”只做 `git add`(须先保存;结果里还有标记时先问),两者分开(§13.3);另有“用当前侧的文件”“用传入侧的文件”(`checkout --ours/--theirs`),一侧没有该文件时还有“以删除解决…”(`git rm`,先问)。符号链接、子模块、任一侧含 NUL(Git 视为二进制)、不是 UTF-8 或缺少的冲突整份解决:写明情形,给“用当前侧的文件”“用传入侧的文件”(只给有该文件的一侧)、“标为已解决”与“以删除解决…”。重读时结果有未保存的手改即保留并提示。真窗口(macOS):合并冲突的提示条、三侧与按块取另一侧后结果更新。未验证:Windows 与 Linux;交互式变基(不提供)。
- 2026-10-07:ElyTree 提交、历史、Blame(C13.3,TREE-05、06)。左栏分“改动”与“历史”两页(`navigation::Tabs`)。提交:改动页下方是 `git::CommitInput`(摘要与说明两个框,摘要满 50 变黄、72 变红),上面一行是 Git 设置里的身份(每次状态重读与窗口回到前台时重读,只取最新一次;`user.name <user.email>`,未设即写明,点它开身份对话框:姓名、邮箱(两个输入框以其标签为无障碍名),可勾“用于所有仓库”写 `--global`,否则写本仓库;空着即提示不写)与签名(`commit.gpgsign` 为真时写明以 `gpg.format` 签名,默认 openpgp),签名照用户自己的 Git 与工具,Ely 不另设。提交框按仓库各存一份(换标签时存下当前的、换上另一个的:摘要、说明、修改最近提交与其草稿),提交为 `commit --cleanup=whitespace -F -`(信息经标准输入,`#` 开头的行照留,用户的 `commit.cleanup` 不删它们,不经 shell),“修改最近一次提交”勾上即显示为勾选、读出上次的信息填入(读回前不能提交;先存下已键入的,取消勾选时放回;每次勾选或取消、换仓库都使在途的读取作废,读回时须仍是发起的仓库与那一次),提交成功后只在提交框仍是所提交的内容时清空(提交期间又键入的留着,别的仓库的提交框不动);钩子或签名失败时以对话框显示 Git 的全部原话,不只说失败。提交同其他写操作排队。历史:`log --topo-order --exclude=refs/stash --all`,每页 2000 个,滚到距末尾 20 行内读下一页;每次状态重读后比对 `show-ref --head`,引用变了(含外部提交、切分支、取回)即从头重读;读取期间来的比对记下,读完再比;引用读不出时历史栏显示 Git 的原话,之后读通即重读并清掉;提交图由 `git::lanes` 按已读的全部提交算出;时间按本机时区写到分钟(不用相对时间,长列表不必随时刷新)。搜索:在“提交信息”(`--grep`)、“作者”(`--author`)、“改动的文字”(`-S`)中按字面、不分大小写找,或按提交号(`rev-parse --verify`,前缀可);搜索时不画图。选中一个提交:中间上方是摘要、说明、作者与提交者(不同时才列)、完整提交号、父提交(可点,转到父提交)、签名状态(`%G?` 按字母译出,带签名者)、引用;下方左列是相对第一个父提交改动的文件(`diff --name-status -M` 与 `--numstat`,根提交对空树,空树由 `hash-object -t tree` 算,不写死 SHA-1),右边是所选文件的差异(与改动区同一读法:Git 自己的差异,图片、二进制、非 UTF-8 同样处理,无块的纯重命名显示为无改动)。改动区差异与历史页所选文件的标题栏都有“文件历史”与“逐行追溯”(改动区限已跟踪的文件):文件历史为 `log --follow --numstat -z`,跨重命名,每个提交带当时的路径与行数,选中即显示该提交里这个文件的差异(状态按两侧是否存在判定);逐行追溯为工作树文件的 `blame --porcelain`,未提交的行写“尚未提交”,每段首行可按,转到历史页的该提交;条色按提交时间新旧。读取都在后台,按发起时的仓库、提交与文件核对;文件历史与逐行追溯每次打开有窗口内唯一的序号,迟到的结果即弃。提交详情的作者、提交者、提交号、签名、引用、摘要与说明在 AX 树里有文字。真窗口(macOS)经 AX:历史页、提交图、引用、提交详情、文件列表与差异。未验证:Windows 与 Linux;GPG 与 SSH 实际签名(只读设置,签名由用户的 Git 完成)。
- 2026-10-07:ElyTree 差异与暂存(C13.2,TREE-03、04)。差异一律取 Git 自己的输出(`-c diff.suppressBlankEmpty=false diff --no-color --no-ext-diff --no-textconv -U3`,前缀写明 `a/`、`b/`,覆盖用户的 noprefix 等设置;空白的上下文行照样带前导空格):已暂存为 `--cached -M`(HEAD 对索引,即提交前的实际索引内容),改动为索引对工作树,未跟踪为 `--no-index` 对 `/dev/null`(退出码 1 即有差异);Git 的清理过滤(换行转换等)由它自己做,按块、按行暂存的补丁对得上索引。块之间未改动的行从旧侧的 blob 取(`cat-file blob`,已暂存为 `HEAD:旧路径`,改动为 `:路径`),可展开;行号按块头推算,旧文件比差异短即报错。改动的词按每对删去与加入的行标出(Unicode 词)。空白:精确、忽略空白变化(`-b`)、忽略全部空白(`-w`);行内与并排。按块或按行:只在精确比较、且是普通内容改动时(无新建、删除、重命名、复制、权限变化的头),已暂存区为取消暂存(`apply --cached --reverse`),改动区为暂存(`apply --cached`);未跟踪、重命名等整份暂存,界面写明。补丁由所选行切出:暂存时未选的删去行留作上下文、未选的加入行不写;取消暂存时反之;块头按“范围前的行数”换算,空范围照 Git 的写法;“无换行结尾”按切出后两侧的实际行尾:无换行的行在哪一侧后面还有行,那一侧就带上换行(所选的删去或加入行同样),两侧不同的上下文行拆成一删一加;`apply` 带 `--whitespace=nowarn`,用户的 `apply.whitespace` 不改所选的字节。写入前在后台重读同一条差异,与所选时读到的字节不同即拒绝(“文件在显示后改了,未暂存”),索引不动;同一仓库的写仍排队。所选行按住 Shift 取同一块内的范围;差异一变(任何重读得到不同的输出)所选即清空;每次读取有窗口内唯一的序号,换文件后旧文件迟到的读取即弃。图片(png、jpg、gif、webp、bmp、tiff、ico)以 `BeforeAfter` 左右拖动对比,一侧缺失时只显示另一侧;其他二进制文件,以及两侧任一侧不全是 UTF-8 的文件(差异以外的字节也算),显示两侧大小,整份暂存;文件与符号链接互换时 Git 为同一路径打出两段(删去与新增),两段都显示(不折叠:两段没有共同的旧文),整份暂存;工作树侧照 Git 读:符号链接读其自身的路径,不跟随;未跟踪的文件夹与冲突文件(冲突在 C13.4 处理)只说明。单击文件即在中间显示其差异(已忽略的文件以系统程序打开),标题栏另有“打开文件”。外部改动后状态重读,正显示的差异随之重读;文件不再列在原分区即收起。真窗口(macOS):改动区选两行暂存,索引恰为所选,已暂存区显示实际索引内容,并排视图;AX 树中各行、块按钮与分段可按。
- 2026-10-07(用户定):ElyTree 的 Git。随包自建钉住的 Git v2.56.0(a018953)加 git-lfs v3.8.0(各平台散列钉在 `scripts/fetch-git-lfs.sh`),`scripts/build-git.sh` 以 RUNTIME_PREFIX 构建到 `engines/git`,LFS 放在 `libexec/git-core`。查找次序:`ELY_GIT`(系统 Git,低于 2.40 即拒绝),否则应用旁或 `Resources/git`,否则本构建的 `engines/git`;没有即报错,不退回别的 Git。每次运行都是参数数组,带 `GIT_OPTIONAL_LOCKS=0`(读取不抢索引锁)、`GIT_TERMINAL_PROMPT=0`(从不在终端问凭据)、`GIT_LITERAL_PATHSPECS=1`(路径不当通配)。
- 2026-10-07:ElyTree 仓库与工作区(C13.1,TREE-01、02)。窗口经 `ely_ui::tool_root`:“文件”只有新建(选文件夹 `git init`)与打开(可多选文件夹),`commands::menus` 改收各应用自己的文件命令(文档应用照旧六项),保存、另存、导出、打印的动作在工具窗口里不挂;“仓库”菜单:克隆…、关闭仓库(Cmd+W)。每个仓库一个标签,同一工作树(按 `--show-toplevel` 的真实路径)只开一个,再开即切过去;裸仓库拒绝。最近列表在数据目录的 `recent.json`(新的在前,留 20 个),读不出即报错且不覆盖;最近项打不开即移出并说明。克隆:先问地址,再在最近仓库旁问新文件夹(名取地址末段去 `.git`),文件夹非空即拒绝;Git 的进度逐行显示在正中(回车与换行都断行,读到即显示),可停止(SIGTERM,Git 自己删去半成品;已结束的进程不再发信号,免误伤重用的进程号);关窗或退出时停下进行中的克隆。状态读 `status --porcelain=v2 -z --branch --untracked-files=normal --ignored`,未知字母即报错;行数取 `diff --numstat -z`(已暂存与未暂存各一次,二进制不计),未跟踪、冲突、忽略的不计行数。左栏 `git::ChangesList` 分冲突、已暂存、改动、未跟踪、已忽略;单击文件以系统程序打开;暂存(`add --all`)、取消暂存(有提交时 `restore --staged`,首个提交前 `rm --cached -f`:索引与文件不同也照取消,文件不动)、全部暂存(`add --update`)、全部取消(`reset -q`);取消暂存重命名连旧路径,暂存只用新路径(已暂存的重命名再改时旧路径已不在);放弃改动每次都问(已跟踪的 `restore --worktree`,未跟踪的 `clean -f -d` 并写明会删除)。同一仓库的写操作排队、一次一条,失败即清掉其后的排队并报出 Git 原话,关闭标签要等排队写完。外部改动:`notify` 监视工作树、Git 目录与共用目录(`--git-common-dir`;链接的 worktree 的分支与引用在主仓库里),250 毫秒内的一串改动合成一次重读,读取中再来的合成一次;空闲不读。状态栏:分支(游离显示短提交号,无提交写明)、上游与领先落后(上游引用已删即写明不存在,不显示为 0)、真实路径、Git 版本、随附或系统与所用 Git 的路径。中间区在 C13.2 前只显示改动数或“没有要提交的改动”。组件库 4f8f3bd 与 37c5187:`Changed::lines` 可缺。真窗口(macOS):五个分区、行数、状态栏。未验证:Windows 与 Linux(停止克隆在非 Unix 上报未构建);LFS 仓库的克隆。
- 2026-10-07:ElyPDF 输出与文件恢复(C11.8,PDF-13、14,QA-13)。“文件›导出…”(Cmd+E)一个对话框三种:页面图片(PNG,72、150、300 dpi,全部或所选页,总是浅色,选文件夹,`名称-序号.png` 按页数补零,先以 `ely_files::claim_all` 占下全部目标,任一失败即删去本次全部);文字(UTF-8,各页以换页符分开,MuPDF 的 stext);优化的 PDF(可看可改的设置:压缩数据流、字体与图片;图片高于某 dpi 降到某 dpi,双三次重采样到确切目标,重压缩:无损的仍无损、有损的保持原格式,默认 300 降到 150;降到的 dpi 须低于阈值,否则不可导出;完成提示写明原文件与新文件大小)。导出图片与文字要“复制”许可,优化副本要“修改”许可。“打印…”(Cmd+P)把文档此刻写成副本(要“打印”许可,worker 的 `PRINT_COPY`)再交系统打印面板(`ely_office::print_later`)。文件恢复:MuPDF 打开时修复了文件结构(`pdf_was_repaired`),worker 即把修复后的文档整份写成临时副本、改开这份副本(日志与快照从此正常;原先修复过的文件不能保存),关闭文档时删去;打开时 MuPDF 的警告记下作修复报告(`ely_open` 期间接管警告回调,照样转给原回调)。外壳的 `Engine::signed` 改为 `Engine::risk`(宏由外壳判,签名、修复由引擎答),加 `Risk::Repaired`:提示条说明打开的是修复的副本、“另存为”写新文件、原件不动,带“报告…”按钮显示 MuPDF 的原话;保存一律另存,另存到原件被拒;签名或修复的文件另存成功后标签记下“待重开”,到内存与文件一致(已保存、无待落地编辑、不在导出)时才按新文件重开;另存期间又有编辑则留着编辑,下一次保存后再重开。MuPDF 有时读到坏对象才修复(尾部完好、某对象偏移坏):打开与解锁后先把每个对象读一遍,并解码图片以外的每个数据流(`ely_scan`,读不出的对象记入报告);MuPDF 修复了结构或读的过程中有任何警告(如内容流解压失败),都当作损坏文件,改开整份写出的副本、报告列出警告、保存一律另存。图片(用户定)在画出时检查:画图块或缩略图时 MuPDF 有警告即为损坏,报告补上警告,标签转为修复风险(保存一律另存);没画过的图片在原位保存(覆盖文件)之前统一解码检查(外壳的 `Engine::unsound`,有问题即转为另存为)。全部解码放在打开时的代价实测:100 张 2000×2000 的 JPEG(281 MB)打开到首块 2.6 秒(release,本机)。导出图片与文字期间 MuPDF 有警告即报错不写(读不全的内容不当作导出)。打印副本不带加密(已核过打印许可;否则系统打印面板打不开加了打开密码的副本),`print_later` 在面板关闭后删去它。导出对话框的降采样数值随键入生效(不等回车或失焦),直接点导出用的是所见的数。补丁 `0005` 见上(保存打不开输出时的保存中标志)。QA-13:worker 改字、加并填文本字段、移页后保存,以 PDFKit(另一阅读器)读回:页序、新字取代旧字、字段值都对(`tests/independent.rs`,只在 macOS);图片编辑在 PDFKit 里未单独核对(PDFKit 无图片清单接口)。组件库 a6b1bb0 的 `init` 改为返回错误(缺资源时),启动时出错即以 `startup::fail` 报出。真窗口(macOS):损坏文件的提示条与“修复的副本”状态、导出对话框。未验证:Windows 与 Linux 的打印(打印面板只在 macOS 实现);真窗口里实际打印。
- 2026-10-06:ElyPDF 密码与签名(C11.7,PDF-11、12,QA-15,§11.4)。权限:worker 记下每个文档的访问(MuPDF 的认证结果,4 为所有者;未加密即全权),`PERMISSIONS` 按 Ely 的位报出(打印、复制、修改、批注、填表、页面与书签、所有者),每个改动请求先按表核对(`secure::needs`:页面增删移转与书签要“页面与书签”或“修改”,内容、链接、属性、附件、OCR 层、删除与清理要“修改”,批注要“批注”,建改字段要修改加批注,填表要填表或批注,提取要复制,改保护要所有者),不许即以 `restricted:名` 报错,客户端转成 `PermissionDenied`,界面按名说明并提示输入所有者密码;复制文字在应用里按位拦(读文字本身不拦)。“文档”菜单“输入所有者密码…”:对时解除限制;错时 MuPDF 已丢了原密钥(RC4 与 AES-128 实测文字读不出),worker 以原密码重新认证。“密码与权限…”只在所有者权限下:写新文件(AES-256,`ely_protect` 经 `ely_save_copy` 带写出选项),打开密码可空,所有者密码必填且须与打开密码不同,六项许可勾选;或不保护(去掉加密)。只凭用户密码打开的文件改保护即拒绝。签名:MuPDF 以 `HAVE_LIBCRYPTO` 构建,worker 另链 `libmupdf-pkcs7.a` 与 OpenSSL 的静态 `libcrypto.a`(pkg-config 给目录)。`SIGNATURES` 按表单字段树列出全部签名字段(不画在页面上的也列,页与框可无;控件无 /P 时从各页 /Annots 找页):已签的给出摘要核对(`pdf_check_widget_digest`:未改、已改、读不出)、其后有无更新与更新是否越出签名所许(`pdf_signature_incremental_change_since_signing`、`pdf_validate_signature`)、签名者(MuPDF 的识别名与通用名)、签名者自称的时间、原因、地点,以及签名里的证书(签名者在前,DER);单个签名读不出只报该项的原因。证书信任由应用交系统判定(`ely_platform::trust`:macOS 用 `SecTrust`、`SecPolicy::create_x509`、禁网,故从不核验吊销;Windows 与 Linux 未构建,报“此平台未构建”)。界面“文档›签名…”对话框逐个列出完整性、证书、吊销(一律“未核验吊销状态”)、时间(带时区)、原因、地点与所在页,不给笼统的“有效”。新签:“以证书签名…”工具,拖出框或点空的签名字段,对话框选 .p12/.pfx、填证书密码、原因、地点,另存:快照副本上建字段(名 `SignatureN` 避开表单已有名)并签,以增量更新写出(旧签名覆盖的字节不动),写完重开核对新签名的摘要,不过即报错删文件;原文档不动;发起时绑定视图版本与所选位置。补丁 `patches/mupdf/0004-signature-reason-and-location.patch`:原因与地点写进签名字典(MuPDF 原只画进外观;签后再改会落到第二个更新段,字节范围算成负数)。外观只画标签、名字与时间(不画空的识别名各栏,不画 MuPDF 标志)。已签文件(`ely_signed` 数表单里有值的签名字段)打开为 `Risk::Signed`:上方提示条说明修改会使签名失效、“另存为”写出未签名副本,带“签名…”按钮;保存不覆盖原文件。外壳的 `risk` 由布尔改为 `Option<Risk>`(宏或签名),`Engine::signed` 由引擎回答。Ely 一切整份写出(保存、另存、提取、删除、清理、保护)在 `ely_save_copy` 里去掉签名值与其外观(字段留下、空着,去掉 AppendOnly 标志):整份重写本就使签名失效,不留假签名。签名核对的是文件,不是编辑中的文档:`SIGNATURES` 带路径,worker 以文档的密码另开该文件读出再关闭;签名列表按标签当前的文件路径读、按路径缓存,保存后重读。已签文件另存为成功后,标签按新文件重新打开(外壳的 `reload`,同冲突时;撤销历史随之重新开始),打开着的文档、签名列表与以后的签名都对应这份未签名的副本;另存失败时标签与签名都仍是原文件。签名对话框每次打开都重读文件,磁盘上被改动即显示已改。去掉签名值时一并去掉该签名字段自身的只读位(签名时由它的锁设下),以便再签;它锁住的其他字段的只读位留着(分不出原本是否只读,已知限制)。补丁 `patches/mupdf/0005-failed-save-clears-save-in-progress.patch`:MuPDF 的 `prepare_for_save` 置起保存中标志、只有写出过程清除它;打不开输出文件时标志留着,此后一切改动绕过日志,编辑没有撤销步(实测:存到不存在的目录后旋转,历史标记仍为 0);补丁让打开输出或预排签名失败时清除它。可视签名(用户定三种来源):“添加可视签名…”对话框,手写(组件库 `SiLine truncated
- 2026-10-06:ElyPDF 内容删除与文档清理(C11.6,PDF-09、10,§11.4,QA-14)。标记只在视图里(“标记文字以删除”按所选文字每行一框,“标记区域以删除”拖框,单击已有标记取消;预览为页面上的深色框;任何编辑落地即清空,页码可能已变),文档本身不动。“删除标记的内容…”与“清理文档…”都写新文件:先把文档快照写出、重开副本(`ely_save_copy`,提取与保存同用),在副本上改,再以 `garbage=2` 整份写出(不增量,旧修订不留),写到工作目录、核对通过后经 `ely_files::export` 交到所选位置,工作副本普通删除(对话框写明:原件、旧备份、同步历史与已发出的副本仍可能含有被删内容;不是磁盘级擦除)。删除:先删去碰到区域的批注与字段(MuPDF 的删除只管链接与文本框批注,其他批注的内容、字段的值会留下),字段失去最后一个控件即连同空了的上级一起从表单去掉(否则值留在字段树里);全部区域的删除做完后才建 Redact 批注(否则后一区域会删掉前一区域的标记)并 `pdf_redact_page`(黑框;图片在区域内的像素由 MuPDF 改成白色;碰到区域的线条整条删去,只删被盖满的会留下可复原的路径;碰到的文字删去,含隐藏文字)。有区域的页再过一遍内容(含表单):标记内容的 /ActualText、/Alt、/E 一律去掉,结构树里属于该页的元素(自身或继承的 `Pg`,或子项 MCR、OBJR 的 `Pg` 指向该页,连同其上级)去掉这三项与 /T;整页去掉而不只区域内的,宁多勿漏。页面缩略图 /Thumb 删去(它可引用原图),核对时区域所在页仍有缩略图即失败。写出后重开核对:区域内的字(与删除同一判法:字框各边缩一成后相交)为零;区域内画出来不是黑色的像素为零;逐页以设备重放,落进区域的路径、渐变、图像蒙版除我们的黑框外为零,图片单独画出在区域内只剩白或黑;文件只有一个版本;任一不过即报错并删去输出。确认时绑定标记与文档版本,选完路径后标记或版本变了即不写。清理:先扫出数量(属性与元数据条数、批注、附件含批注里的文件、有隐藏文字的页、脚本入口),勾选后写出:删 Info;其余按一遍遍历全部对象及其中的直接字典与数组:/Metadata 与 /PieceInfo,文件说明的 /EF 与 /RF 及关联文件 /AF,脚本;删批注(链接与字段不算批注,保留),删附件(名称树与文件批注;附件按带 /EF 的文件说明计数,每个一次),隐藏内容为:内容过滤(包住 MuPDF 的 sanitize 过滤器,用途设为 View 才认关着的图层)删去关着的图层里的一切、3 与 7 渲染模式及零透明度的文字,脚本(名称树 JavaScript;任何对象的 /A、/OpenAction、/Next 与 /AA 各项里 S 为 JavaScript 或带 /JS 的动作,从链中摘除,其后的 /Next 链接回原处,非脚本动作保留),带隐藏或不可见标志的批注(含链接与字段控件:链接不在 MuPDF 的批注表里,按页面 /Annots 删;控件删去时字段连同值从表单去掉);有隐藏文字被删的页,标记内容的替代文字照删除的做法去掉。扫描与写出后的核对都在快照副本上跑同一遍清理来计数,所以计数与执行一致;所选类别核对不为零即报错。扫描回来时须仍是当前标签且版本未变,对话框写明文件名。未验证:Windows 与 Linux。
- 2026-10-06:ElyPDF 文档结构与 OCR(C11.5,PDF-07、08)。MuPDF 以 `tesseract=yes` 构建(自带的 Tesseract 与 Leptonica,C++,worker 另链 libc++/libstdc++);构建脚本记下所用参数,参数一变即整个重建(make 不跟踪参数,曾留下未开 OCR 的旧目标文件)。语言数据(用户定 tessdata_fast:eng、chi_sim、chi_tra)由 `scripts/fetch-tessdata.sh` 按钉住的提交取到 `engines/tessdata` 并核对散列;运行时取 `ELY_TESSDATA`、应用旁的 `tessdata`、或本构建的目录,缺 eng 即报错。OCR:worker 把页面按 300 dpi 画成灰度图,只让这张图经 MuPDF 的 OCR 设备进 stext(不把页面原有内容交给它),按行回报页面坐标的外框与文字,不改文档;应用逐页在后台读,可取消,每页回来时核对视图版本,读完以一步写入隐藏文字层:无字形的 Identity-H 字体(ToUnicode 恒等映射),每行一个 `3 Tr` 的文字对象按外框横向拉伸,写在单独的内容流里(自带 `q … Q`,3 Tr 不漏到后来写的字)并以 `/ElyOCR` 标记;“重新识别”先删去这些流再写,原有文字不动;“跳过”不读已有文字的页。BMP 以外的字拒绝整层(已知限制)。补丁 `0003-ocr-needs-every-language.patch`:Tesseract 只载入能载入的语言、静默丢掉其余,补丁让载入数少于所请求时报错。每次识别有自己的编号,取消后的页回来即弃(不再按版本与页码认);页码范围随键入更新。补丁 `patches/mupdf/0002-embedded-file-and-link-uri.patch`:`pdf_add_embedded_file` 成功时未结束它开的操作,之后撤销与状态读取都抛错;`pdf_set_link_uri` 写了 `/A` 却留着旧 `/Dest`(读取先看 `/Dest`),改链接目标无效。书签:添加(在选中项之后,否则末尾)、改名、改目标、删除(连子项),按自顶的路径定位并核对标题,经 MuPDF 的大纲迭代器在一步内完成。链接:读取每页的外框、地址与内部目标;阅读时单击即跳转(内部)或打开(只 http、https、mailto,其他拒绝并告知);“添加链接”工具拖出外框,目标为页码或网址;单击已有链接改目标或删除,按序号并核对地址。文档属性:标题、作者、主题、关键词可改(只写改过的,一步),创建与生成程序及时间只读,文件没有的显示“无”。附件:EmbeddedFiles 名称树按名列出,再列各页 FileAttachment 批注的文件(标出页码),以键区分(`n:名称`、`a:页:对象号`,重名不混);大小取 `/Params /Size`,缺即按实际字节(不把缺失显示成 0);类型、说明,另存为(经 `ely_files::export`)、添加(文件名为名,重名拒绝)、移除;改动时名称树重写为一层有序的 `/Names`。左栏分段为页面、书签、批注、附件,书签与附件为空时显示空状态;“文档”菜单:文档属性…、识别文字…、添加书签、添加链接、添加附件…。真窗口:扫描页识别后可查到文字、页面观感不变。移除批注里的文件即删去该批注。未验证:XMP 元数据不随属性修改;Windows 与 Linux 上的 OCR。
- 2026-10-06:ElyPDF 批注与表单(C11.4,PDF-05、06)。垫片 `ely_annot.c`、`ely_form.c`:读一页的批注与字段(JSON:对象号、种类、外框、颜色、内容、作者、印章名;字段的全名、值、开状态、是否选中、只读、选项与显示文字);批注与字段按对象号认定,撤销后不变,页上没有即拒绝。添加批注收 JSON 列表(可跨页,一步):高亮、下划线、删除线(按所选文字每行一框)、便签(内容)、矩形、椭圆、直线、手绘、印章(MuPDF 的 14 种标准印章);全部检查在开步之前,不合即不改、重做不丢。作者取账户全名(`ely_platform::user::name`)。改批注的颜色与内容、删除批注各一步。字段(MuPDF 无建字段接口,垫片自建):文本、复选、单选、选择框(组合框);名字不可含点、不可重名;同名单选并入同一组(各按钮的开状态为 ChoiceN,开状态先画外观再设回 Off);改名、改选项(原值不在新选项里即清空)、删除(组里最后一个按钮删去即删组)各一步。填写:文本与选择框经 `pdf_set_field_value`(不跑脚本),复选与单选设组头 /V 与各按钮 /AS;只读字段拒绝。外观在同一步里生成。界面:“批注”菜单(高亮、下划线、删除线、便签、印章、矩形、椭圆、直线、手绘)与“表单”菜单(文本字段、复选框、单选按钮、选择框)各为工具,Esc 退出。阅读时单击复选与单选即填写,单击文本与选择框在右栏填写,单击批注在右栏改颜色、内容或删除;表单工具下单击已有字段改名、选项或删除,拖出新字段(单击给默认大小)后在右栏取名再添加;新单选默认取本页最后一组的名字。按下即改的操作须视图无排队编辑,右栏的改动按发起的标签与版本核对。右栏的开合在拖动与绘制期间保持手势开始时的样子,手势按开始时该页在窗口中的位置与比例换算(之后页面因右栏开合或滚动移动也不影响);单击按窗口像素判定(移动不足 3 像素)。文字未读到时拖出的标记等文字到后再加。新字段的默认名避开表单里的全部名称(worker 读整棵 `/AcroForm/Fields`,含没有页面控件的节点与父名),右栏等它读完再给;改名只改名字末段,按父路径下的全名查重。左栏“批注”列出全文批注(每批 20 页在后台读),点一行转到所在页。组件库 3d08744:`DescriptionList` 的值占满所在列。未验证:已有文件里的签名字段与按钮只读不改;多选列表框只当单选填;Windows 与 Linux。
- 2026-10-06:ElyPDF 文字、图片与对象编辑(C11.3,PDF-03、04,§11.3)。字体(用户定):文档自带字体(能写新字时)或 MuPDF 内置的 Helvetica、Times、Courier(不嵌入,WinAnsi)与 Noto 中日韩(不嵌入,UTF-16 的 Adobe CJK 字体),不扫描系统字体;自带字体以 CID 字体(Identity-H,按字形号写)写入。对象(用户定):图片与表单 XObject。文字块以 MuPDF 的 stext 为准(同一读法,块序号稳定);可编辑的块须横排,且每个字形都在页面自身的内容流里(以不落盘的过滤试跑核对,表单里的字不可改)。改字是一步:用内容过滤的 `text_filter` 去掉该块的字形(按字形原点在用户空间 0.5 点内且字符相符,不靠白块遮盖),再在原首行基线处按框宽排版写新字(空格处折行,太长的词断开,行距沿用原块);所有检查(块文字未变、横排、字形都在页面内容里、字体画得出每个字、排版)都在开步之前,拒绝时文档与重做都不动;画不出某字即告知是哪个字,界面请用户换字体;换了字体、字号改变或新文字越出原块的框时先出预览(在快照副本上改并画出该区域,文档与撤销历史都不动),确认后才写。行距取首行与下一条不同基线的距离(同一基线的几段不算行),没有即字号的 1.2 倍。新文字在点击处起框。新增内容第一次写入时把页面原内容包进 `q … Q`(以 `%Ely` 标记,只包一次)。图片与表单:清点处理器按 `q`、`Q`、`cm` 跟踪,列出页面顶层的每次 `Do` 与其页面框;移动、缩放(右下角柄,保持比例、左上不动)以 `X = C·T·C⁻¹` 包住该次绘制重写内容流,删除即不写该次 `Do`,替换图片是新图片资源、按比例放进原框中央;添加图片落在当前页中央(像素按点、缩到页面一半以内)。MuPDF 的字体与图片资源缓存不随撤销与放弃回退(实测:预览后再写,字体引用指向已不存在的对象),每次放弃、撤销、重做后清空。编辑工具在“编辑”菜单(编辑文字和图片、添加文字、添加图片…),Esc 退出(键位上下文在窗口根上,页面与面板里都管用);替换、添加与删除图片和表单、拖动移动与缩放、写字与预览的确认都绑定发起的标签与视图版本,执行前视图须未变且无排队的编辑,否则提示不执行;任何编辑落地即关掉此前的预览;图层读取在回来时总清掉进行中的标记,编辑之前发出的读取作废后重读;右侧编辑面板:文字、字体、字号、颜色、框宽,应用、删除文字;对象:替换图片…、删除。真窗口(macOS)改过一次字:同字体同位置替换,标签为已编辑。
- 2026-10-06:ElyPDF 页面组织(C11.2,PDF-02)。worker 打开即开 MuPDF journal;插入空白页、从文件插入(整份 PDF 嫁接)、删除、移动、旋转(90 度倍数,写页面的 `/Rotate`)、裁剪(按所见的上右下左边距设 `/CropBox`,全零即去掉)、撤销与重做都是一步,回复带历史标记(worker 按 MuPDF 的撤销位置记每步是编辑还是保存,标记是最后一个已做的编辑;撤销重做跳过保存步)。删除时指向已删页的书签保留但失去跳转,链接移除,表单字段去掉已删页上的控件;页码标签由 MuPDF 的删除与插入随之平移。坏的编辑(删光、页码乱序、非 90 度、边距超出页面)拒绝并回滚整步。保存(Save、Save As 走外壳)先 `pdf_save_snapshot` 写出(不动内存中的文档与撤销历史,靠补丁 0001),再打开该副本以 `garbage=2` 紧缩写到工作目录(已删页的对象不留在文件里),加密文件以用户输入的密码重开副本、照原加密写出(密码只在 worker 内存);外壳读回字节、原子写入。提取(选中页写新文件,经 `ely_files::export`,不覆盖打开着的文件,被别的标签持有即拒绝)在快照副本上删去其余页再紧缩写出,原文档的撤销与重做都不动;拆分按每份页数写到所选文件夹(`名称-序号.pdf`,先以 `ely_files::claim_all` 占下全部目标,任一已存在即一个不写;原 ElyShow 的同名函数移入 ely-files 共用)。合并即从文件插入到末尾再另存;插入的页带着自己的批注、链接与表单字段(指向源文件自身页的链接改指插入后的页,字段并入表单,重名的加序号,如 `field0 2`(名称按需分配,长名也终止),源表单的默认外观 `/DA` 与默认对齐 `/Q` 给到没有自己值的字段树根,源表单的字体资源并入目标 `/DR`,同名而不同的字体改名为 `名称_2` 并改写字段外观里的引用(按 PDF 名称语法解 `#xx` 转义,写出时照样转义);源文件的书签不并入)。删除时表单控件按被删页的 `/Annots` 认定,不只看可省略的 `/P`。裁剪边距以所见为准:先按视图旋转、再按页面 `/Rotate` 换到页面自身方向,并按 `/UserUnit` 换算。编辑失败也通知外壳队列已动,等着的保存、关闭不会挂住。结构编辑后区间点选的起点作废。应用:每个视图一条编辑队列,一次一条、按序执行,落地后重读页数、尺寸与书签,清掉图块、文字、缩略图、找到的词与选择,视图版本加一,此前发出的读取回来即丢;查找在编辑后重查。修改标记是当前历史与载入或上次保存时的历史不同,撤销回到保存点即为已保存(外壳新增 `Engine::saved` 与 `Session::reverted`:引擎回报未修改时 Dirty 回到 Saved,保存中、冲突、失败不变)。左栏是页面缩略图(`side.rs`;有书签时上方分段切换到书签):点选、Shift 连选、Cmd 增减,右键菜单,拖到某页之前即移动(移动后仍选中),Delete 删除;无选中时作用于当前页。“页面”菜单与“编辑”菜单的撤销(Cmd+Z)、重做(Shift+Cmd+Z)。裁剪对话框以毫米输入。MuPDF 的构建改为在 `engines/mupdf-src`(reference 只读)打 `patches/mupdf` 后编译。
- 2026-10-06:ElyPDF 阅读(C11.1,PDF-01)。MuPDF 只链进 `ely-pdf-worker`(`crates/ely-pdf-engine`,build.rs 把 C 垫片 `shim/ely_mupdf.c` 与 `libmupdf.a`、`libmupdf-third.a` 只链给这个二进制),应用不链 MuPDF。build.rs 以协议、垫片与 worker 源码的散列为版本戳,应用启动 worker 先握手(`HELLO`),戳不同即拒绝(单测不重建 worker,旧二进制曾被误用)。worker 在打开第一个文件时才启动(PERF-01),`tests/scripts/pdf-idle.sh` 查空白窗口无子进程。垫片每个调用都在 `fz_try`/`fz_catch` 里,返回码加最后一条错误信息,MuPDF 的跳转从不越过 Rust。worker 与应用之间是 `ely-ipc` 的帧(从 ely-lok 抽出,两者共用):打开(锁定即报)、解锁、页数、页框、渲染一块(设备像素、缩放、旋转、深色)、按阅读顺序的字符与外框、页内搜索(每处命中及其全部四边形,跨行短语算一处,数量不设上限)、书签(JSON)、关闭;文件读不出只报错,worker 照常工作。查看器:打开时只读页数、前 100 页尺寸与书签,其余尺寸后台每批 100 页补齐;视图只排屏上的页(连续、单页、双页;适合宽度、适合页面、实际大小与缩放档;视图旋转 90°),按 512 设备像素一块请求,一次一块、由近及远,只留屏上的块(横纵两向),后台回来的块不在屏上即弃;画不出的块记下不再请求,换文档或重开前不重试;深色外观下页面按亮度反相(MuPDF `fz_invert_pixmap_luminance`,色相保留)。文字选择按字符、可跨页,复制按行换行,未读的页先读;查找每批 20 页在后台进行,命中逐个跳转并标出,Esc 关闭(组件库 617a8d6);书签栏用 `lists::Outline`,点即跳到页与位置。切换视图、缩放、旋转后仍停在正在读的那页。加密文件打开时问密码(外壳的密码框,见上一条),错了再问,取消即不打开。没有页的文件报错并关闭,不开标签。双页时同一行在视口内的两页都可为当前页;当前页只在横纵两向都可见的页里定,跳转在目标页左缘不在视口时横向移过去。图块按视口半开区间取。首次按下时文字未读,读到后照原按下处起选;查找结果按查找实例与所属标签核对,只在该标签为当前时跳转。书签为空时不留左栏。启动参数里的文件直接打开。
- 2026-10-09(用户定:“打开方式”里不得出现 LibreOffice):系统会登记磁盘上能找到的 .app、挂载镜像里的 .app 与运行过的包,构建目录里的 `LibreOffice.app` 与测试挂载的官方镜像曾因此成为 xlsx 等的默认程序。现构建后由 `scripts/inert-lo.sh` 去掉其全部文件与 URL 声明、快速查看与聚焦插件,注销它,并在 `engines/` 放 `.metadata_never_index`;Ely 只取其 Frameworks 与 Resources。任何脚本与测试都不挂载 LibreOffice 镜像:LOK 探测用本构建的内核,单测所需的官方内核由 `scripts/extract-official-lo.sh` 以 7zz 解成无包的文件夹。`tests/scripts/lo-unregistered.sh` 以 NSWorkspace 核对 docx、xlsx、pptx、odt、csv 的“打开方式”里没有 LibreOffice。
- 2026-10-09(用户报:ElyTable 点格跳到别处、拖不出选区):Calc 在 `sc_print_twips_msgs` 下以打印 twips(行高原值)报光标、选区与 `.uno:SheetGeometryData`,图块与鼠标却在图块 twips(每行按 `ScViewData::ToPixel` 截成整像素),两者随行号漂移(5000 行处约 140 行)。视图对外一律打印 twips;`LokView::set_geometry` 收下工作表几何后,`sheet::Axis::tile`、`print` 照内核的截断换算(每像素 twips 取图块的像素与 twips 之比),绘制、显示、跳转与冻结都先换成图块 twips,鼠标以图块 twips 发出(`tile_point`),拖动句柄仍为打印 twips;`.uno:` 读回与应用(表头、填充柄)照旧用打印 twips,表头的起点取 `LokView::scrolled_start`。内核读鼠标用的比例原取最后一块图块,worker 现于每次 `setClientVisibleArea` 前以 `setClientZoom` 告知图块比例,点击不再依赖先画过图块。
- 2026-10-09(用户报:ElyPDF 改字报“这种字体画不出 V”):文档自带的子集字体常无可用的 Unicode 表(LibreOffice 导出的子集表为占位,0x00 起逐一对应字形号),按 Unicode 找字形一律失败。现以“文档自带字体”写字时直接用页面上的那个字体对象(`shim/ely_pen.c`;`pdf_load_font` 按对象缓存,与文字层的字体同一对象),以它自己的编码写:按文字提取的读法(ToUnicode,缺项或坏值退回 cid_to_ucs,同 pdf-op-run.c)倒出 Unicode 到编码的表,排版取它的宽度(`pdf_lookup_hmtx`),原有的 ToUnicode 与宽度不动,两个字共用一个字形时各写各的编码(原先按字形号写、重建 ToUnicode,共用字形的字被改写,且 CMap 漏写条目数,PDFKit 读不出字)。删去原文字块后页面资源里可能已无该字体,故按对象号以新名字重新引用。只支持编码即 CID 的字体(简单字体与 Identity-H),其余拒绝并说明;页面上没有的字仍报缺字。排版按同一套宽度量出的框,比较时留 0.01 点的余量(原块宽度即这些宽度之和,浮点误差曾使末字折行)。
- 2026-10-09:ElyTable 的自动筛选(R2.9)。内核会重新推算未命名的自动筛选区:每次打开筛选列表时向下扩到紧邻的数据行(`ExtendDataArea`、`ExtendBackColorArea`,为接上后来追加的行),命令从单格找到它时按连续数据区重算(`ScDocShell::GetDBData`:应用条件、关闭筛选及其撤销);选区下方紧邻的合计行因此进了列表、随数据一起被隐藏,存后重开的筛选区同样会扩。补丁 `0040-lok-calc-autofilter-keeps-its-rows.patch`:LOK 下已有的自动筛选区保持设定时的行(同 Excel),不再隐式扩展(光标在区内时;光标在同表另一块数据里即按那一块);单格开筛选时仍取整个数据区。代价:紧贴在筛选区下方追加的行不自动并入,需重新选定后再开筛选。
- 2026-10-09:ElyTree 的差异按文件类型着色(R2.12)。组件库 7f2ac3e 至 b0dbfd4:`DiffViewer`(建立时按路径定下读法) 按路径的扩展名判定散文与纯文本(md、markdown、txt、rst、adoc、org、csv、tsv、log 等,无扩展名同,不着色),代码按语言着色(组件库 b0dbfd4 的 `forms::syntax`:Rust、JavaScript/TypeScript、类 C、Python、Ruby、Shell、YAML、INI、SQL、Lua、Haskell、Lisp、Erlang、TeX 与其他 `#` 语言各有行注释记号、关键字与常量;Shell 与 YAML 的 `#` 只在词首起注释;Makefile、Dockerfile、CMakeLists.txt 先按文件名认),不是完整的语法分析器(字符串插值、块注释、多行字符串照通用词法)(原先一律套 `//` 的通用词法,Markdown 的 `#`、Python 的注释都被当作代码上色)。同批带入上游的块编辑器跨块选择、浮层滚轮、输入法候选框位置、插入点按设备像素对齐与格式修正。
- 2026-10-09:ElyMark 的表格(R2.13)。表格的空格与分隔行写法有许多种(`|a|b|`、`|---|`、对齐空格),逐字重现做不到,原先凡写不回原样的表格都成了原文块。现在一块的序列化与原文在把其中每张表格化成规范形式(各列对齐方式加各格的原文,`tables.js` 的 `canon`)后相同,即为可视块(`preserve.js` 的 `kept`),引用与列表里的表格同样;格内有 Milkdown 会改写的写法(`[[…]]`、引用式链接等)即不同,整块仍为原文块,与段落的规则一致。Milkdown 的列表项必须以段落开头,表格作列表项的第一块时无法放进编辑器,照原文块保留。未改的表格照旧逐字写回,改过的表格按文档多数表格的写法重写(`style.js` 按表格的行节点投票:单元格两侧有无空格、是否按列对齐,经 remark-gfm 的 `tableCellPadding` 与 `tablePipeAlign`;原先投票的解析器不认 GFM,表格从未计票),分隔行随之重写(如 `|---|` 成 `| - |`)。
- 2026-10-09:ElyTable 新插图表的位置(R2.10)。内核按“定位区域”把图表放在其旁、并挤回当前可见区;带参数的请求(Ely 经 `FN_PARAM_5` 给区域)原把定位区域取为光标格,图表压在数据上或旁边的另一块数据上。补丁 `0041-lok-calc-chart-beside-its-data.patch`:LOK 下有请求区域时,图表放进数据区右侧(隔一列)第一块够大、无合并格的空单元格(逐列向右找,跨过旁边的数据块),右侧没有依次找下方、左侧、上方,再按行带找全表第一块够大的空处(尺寸从候选格起累加,表尾也快),不受可见区限制;空处按图表所在的工作表查,数据在别的表时从光标起找;整张表没有空处即插入失败、文档不动;`LokView` 在新选中对象(原先无选中)时把它滚入视口,拖动中的对象不滚动。
- 2026-10-09:ElyPDF 编辑与缩放不闪白(R2.6)。旧图块不再随编辑或比例变化即刻丢弃:编辑落地时全部当前图块、比例变化时离开视图的同页图块,连同当时的页面尺寸进入“底图”(`apps/ely-pdf/src/backdrop.rs`),按其在页面中的比例位置画在该页新图块之下,直到该页在视口里的新图块到齐、或页面尺寸与旋转变了才释放;每页画什么由 `PdfView::pieces` 决定,绘制与测试同用它。只有改内容的编辑保留底图并沿用已测的页面尺寸(前 100 页以外不再被占位尺寸覆盖);增删、移动、旋转、裁剪页面与撤销重做(`PdfView::edit_pages`)清空底图,因同一序号可能已是另一页。关闭标签时底图随图块交给窗口释放。原先比较整份尺寸表(超过 100 页的混合纸张文档尺寸表后段为占位,比较失败即全部清空)且只认同比例的图块(编辑后右栏收起、比例变化即清空),都会闪白。
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
- 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
- 2026-10-09(用户定):代码与发布从 GitHub 搬到自建的 Gitea(git.zacharyzhang.com;GitHub 账号被封,推送与发布都被拒)。公开仓库 `ZacharyZhang-NY/ElyOffice`(原样推上 main、缺陷清单分支与 v0.1.0、v0.1.1 标签),组件库为同站的 `ZacharyZhang-NY/Ely-GPUI-Components`(Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变)。v0.1.1 的六个 dmg 与两个源码包重新发在 Gitea 的 v0.1.1 发布里。官网的下载与源码链接指向 Gitea(`releases/latest` 会跳到最新发布页)。`.github/workflows` 删去(它们靠 GitHub 的 macOS 机器与 `gh`,发布改在本机以 `packaging/macos/release.sh` 构建、经 Gitea API 上传)。发布视频里写的地址已在源文件改为 Gitea,已发布的成片仍是旧地址,重渲染后才更新。上游项目(LibreOffice、MuPDF、git-lfs、tessdata 等)照旧从它们自己的 GitHub 地址取。
+9 -8
View File
@@ -24,17 +24,18 @@ impl Docs {
let Some(counts) = self.active_tab().and_then(|tab| tab.extra.counts) else {
return status.when_some(zoom, |status, zoom| status.right(zoom));
};
let (words, characters) = (counts.words.to_string(), counts.characters.to_string());
let count = |key, n: u64| ely_ui::i18n::counted(cx, key, n, &[("n", &n.to_string())]);
let label = match counts.selected_words {
0 => Self::text(
cx,
"doc.count.words",
&[("words", &words), ("characters", &characters)],
),
selected => Self::text(
0 => {
let words = count("doc.count.word", counts.words);
let characters = count("doc.count.character", counts.characters);
Self::text(cx, "doc.count.words", &[("words", &words), ("characters", &characters)])
}
selected => ely_ui::i18n::counted(
cx,
"doc.count.selected",
&[("selected", &selected.to_string()), ("words", &words)],
counts.words,
&[("selected", &selected.to_string()), ("words", &counts.words.to_string())],
),
};
status.right(StatusBarItem::new("doc-count").label(label)).when_some(zoom, |status, zoom| status.right(zoom))
+9 -2
View File
@@ -85,6 +85,14 @@ impl Host for Docs {
self.offer_spelling(id, view, cx);
}
/// A dialog about a closed tab goes with it.
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, cx: &mut Context<Self>) {
if matches!(self.dialog, Some(Dialog::Output { tab: of, .. }) if of == tab.id) {
self.dialog = None;
cx.notify();
}
}
fn search_leaves(&mut self, cx: &mut Context<Self>) {
self.mark_place(cx);
}
@@ -126,7 +134,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let root = ely_ui::document_root(app, docs.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
docs.update(cx, |docs, cx| {
docs.core.root = root.downgrade();
docs.core.window = Some(window.window_handle());
docs.own_window(window, cx);
window.focus(&docs.core.focus, cx);
});
let launched = ely_ui::launch_files(cx);
@@ -209,7 +217,6 @@ impl Render for Docs {
.on_action(cx.listener(Self::on_open))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_quit))
.on_action(cx.listener(Self::on_close_tab))
.child(shell)
.children(self.core_dialog(cx))
+29 -9
View File
@@ -36,6 +36,10 @@ pub fn menu(cx: &gpui::App) -> gpui::Menu {
pub struct Merge {
docs: WeakEntity<Docs>,
pub table: Option<Table>,
/// The template's tab, active when the data was chosen.
pub source: Option<u64>,
/// Tabs opened as previews; none is a template.
pub previews: std::collections::HashSet<u64>,
/// The record to preview, from 1.
record: usize,
pdf: bool,
@@ -46,6 +50,8 @@ impl Merge {
Merge {
docs,
table: None,
source: None,
previews: std::collections::HashSet::new(),
record: 1,
pdf: false,
}
@@ -62,11 +68,20 @@ impl Docs {
}
pub(crate) fn choose_csv(&mut self, cx: &mut Context<Self>) {
self.pick(false, cx, |docs, path, cx| match read_csv(&path) {
let Some(source) = self.core.active else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
if self.merge.read(cx).previews.contains(&source) {
let text = Docs::text(cx, "doc.merge.from_preview", &[]);
return self.toast(text, Severity::Warning, cx);
}
self.pick(false, cx, move |docs, path, cx| match read_csv(&path) {
Ok(table) => {
log::info!("doc: merge data has {} rows", table.rows.len());
docs.merge.update(cx, |merge, cx| {
merge.table = Some(table);
merge.source = Some(source);
merge.record = 1;
cx.notify();
});
@@ -84,20 +99,20 @@ impl Docs {
});
}
/// The tab a merge starts from: the active one.
/// The template's tab, while it is open.
fn source(&self, cx: &mut Context<Self>) -> Option<u64> {
if self.core.active.is_none() {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
let source = self.merge.read(cx).source.filter(|source| self.tab(*source).is_some());
if source.is_none() {
let text = Docs::text(cx, "doc.merge.gone", &[]);
self.toast(text, Severity::Warning, cx);
}
self.core.active
source
}
/// Types a field's marker at the cursor.
/// Types a field's marker at the template's cursor.
pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
let Some(tab) = self.source(cx).and_then(|source| self.tab(source)) else {
return;
};
tab.view.update(cx, |view, _| view.insert(&marker(header)));
}
@@ -194,7 +209,12 @@ impl Render for Merge {
})
};
let row = || div().flex().flex_wrap().items_center().gap_1();
let template = self.source.and_then(|source| {
let docs = self.docs.upgrade()?;
Some(docs.read(cx).tab(source)?.name.clone())
});
let group = PropertyGroup::new(table.name.clone())
.row(text("doc.merge.template"), div().child(template.unwrap_or_else(|| text("doc.merge.gone"))))
.row(text("doc.merge.data"), choose)
.row(text("doc.merge.fields"), row().children(fields))
.row(
+3 -3
View File
@@ -164,6 +164,7 @@ impl Docs {
cx: &mut Context<Self>,
) {
log::info!("doc: previewing merge record {}", at + 1);
self.merge.update(cx, |merge, _| merge.previews.insert(into));
self.load_into(into, Source::Copy(copy), cx);
self.core.waiting.push((
into,
@@ -260,9 +261,8 @@ impl Docs {
let folder = folder.display().to_string();
let text = match written {
Ok(count) => {
let count = count.to_string();
let text =
Docs::text(cx, "doc.merge.done", &[("n", &count), ("folder", &folder)]);
let n = count.to_string();
let text = ely_ui::i18n::counted(cx, "doc.merge.done", count as u64, &[("n", &n), ("folder", &folder)]);
return docs.toast(text, Severity::Success, cx);
}
Err((n, error)) => Docs::text(
+49 -4
View File
@@ -22,9 +22,9 @@ fn records_fill_previews_and_files() {
let csv = dir.path().join("people.csv");
std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes");
let table = read_csv(&csv).expect("the csv reads");
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
type_into(&mut cx, &docs, " of ");
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("City", cx)));
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
cx.update(|app| {
docs.update(app, |docs, cx| {
@@ -171,7 +171,7 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
let table = read_csv(&csv).expect("the csv reads");
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.insert_field("Name", cx);
docs.field_here("Name", cx);
docs.preview_record(source, &table, 0, cx);
docs.core.active = Some(other);
})
@@ -191,6 +191,14 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
});
let preview = dir.path().join("preview.odt");
let id = cx.update(|app| docs.read(app).core.tabs[2].id);
// The preview is active now; the template stays.
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.core.active = Some(id);
assert_eq!(docs.source(cx), Some(source), "the template stays after a preview");
assert!(docs.merge.read(cx).previews.contains(&id), "the preview is known");
})
});
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, preview.clone(), None, window, cx)
});
@@ -227,7 +235,7 @@ fn two_generations_keep_their_own_templates() {
for text in ["SourceA ", "SourceB "] {
sources.push(blank(&mut cx, &docs));
type_into(&mut cx, &docs, text);
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
settled(&cx, &docs);
}
let csv = dir.path().join("one.csv");
@@ -265,3 +273,40 @@ fn two_generations_keep_their_own_templates() {
);
}
}
#[test]
fn the_template_chosen_with_the_data_stays_through_a_preview() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let template = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Dear ");
let csv = dir.path().join("two.csv");
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
cx.update(|app| docs.update(app, |docs, cx| docs.choose_csv(cx)));
cx.simulate_path_prompt_response(move |_| Some(vec![csv]));
settle(&cx, |app| docs.read(app).merge.read(app).table.is_some());
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
let table = cx.update(|app| docs.read(app).merge.read(app).table.clone().expect("the data"));
// As the panel's buttons do: preview, then generate.
cx.update(|app| docs.update(app, |docs, cx| {
let source = docs.source(cx).expect("the template");
docs.preview_record(source, &table, 0, cx);
}));
settle(&cx, |app| tabs(app, &docs) == 2 && docs.read(app).core.active != Some(template));
let out = dir.path().join("out");
std::fs::create_dir(&out).expect("the folder");
cx.update(|app| docs.update(app, |docs, cx| {
let source = docs.source(cx).expect("the template, not the preview");
assert_eq!(source, template);
docs.generate(source, table.clone(), false, out.clone(), cx);
}));
let stem = cx.update(|app| docs.read(app).tab(template).expect("a tab").name.to_string());
let target = |n: usize| out.join(format!("{stem} {n}.docx"));
settle(&cx, |_| target(2).exists());
let (first, second) = (text_of(&mut cx, &docs, &target(1)), text_of(&mut cx, &docs, &target(2)));
assert!(first.contains("Dear Ada") && second.contains("Dear Grace"), "{first:?} {second:?}");
}
+1 -1
View File
@@ -29,7 +29,7 @@ impl Docs {
self.ask_output(true, cx);
}
fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
pub(crate) fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
+9
View File
@@ -463,3 +463,12 @@ fn an_outside_change_asks_and_keep_mine_overwrites() {
mod find;
mod more;
mod overlap;
impl Docs {
/// Chooses the active tab as template, then types a field.
pub(crate) fn field_here(&mut self, header: &str, cx: &mut gpui::Context<Self>) {
let active = self.core.active;
self.merge.update(cx, |merge, _| merge.source = active);
self.insert_field(header, cx);
}
}
+48
View File
@@ -215,3 +215,51 @@ fn an_edit_that_just_landed_still_asks_and_selection_does_not() {
});
settle(&cx, |app| docs.read(app).tab(id).is_none());
}
#[test]
fn a_quit_from_either_window_asks_first() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
commit(&mut cx, &docs, "Unsaved");
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
settle(&cx, |app| view.read(app).settled());
cx.update(ely_ui::commands::bind_quit);
let home = cx.add_window(|_, _| Blank);
cx.update_window(home.into(), |_, window, app| docs.update(app, |docs, cx| docs.own_window(window, cx)))
.expect("the window lives");
// The settings window has no quit of its own.
let settings = cx.add_window(|_, _| Blank);
for from in [home.into(), settings.into()] {
cx.dispatch_action(from, ely_ui::commands::Quit);
settle(&cx, |app| matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. })));
assert!(cx.update(|app| docs.read(app).tab(id).is_some()), "the tab waits for an answer");
assert_eq!(cx.update(|app| app.active_window()), Some(home.into()), "the question's window comes forward");
// Cancel, as the dialog's button does.
cx.update(|app| docs.update(app, |docs, cx| {
docs.close_cancelled(cx);
docs.core.dialog = None;
}));
}
}
#[test]
fn closing_a_tab_takes_its_export_dialog() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
cx.update(|app| docs.update(app, |docs, cx| docs.ask_output(false, cx)));
assert!(cx.update(|app| matches!(docs.read(app).dialog, Some(crate::actions::Dialog::Output { .. }))));
with_window(&mut cx, &docs, |docs, window, cx| docs.request_close(id, After::Tab, window, cx));
settle(&cx, |app| docs.read(app).tab(id).is_none());
// Drawn now, a dialog for the gone tab would crash.
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "the dialog went with its tab");
}
+2 -2
View File
@@ -86,10 +86,10 @@ fn interleaved_previews_keep_their_sources() {
};
let alpha = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceAlpha ");
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
let beta = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceBeta ");
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
settled(&cx, &docs);
let csv = dir.path().join("two.csv");
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
+13
View File
@@ -56,6 +56,19 @@ const CHECK: &str = r#"
inline: seen(view.querySelector('.milkdown p .katex')),
display: seen(view.querySelector('.milkdown p .katex-display')),
tasks: view.querySelectorAll('li[data-item-type="task"]').length,
boxes: [...view.querySelectorAll('li[data-item-type="task"]')].map((item) => {
const mark = getComputedStyle(item, '::before');
const range = document.createRange();
range.selectNodeContents(item.querySelector('p'));
const line = range.getClientRects()[0];
const top = item.getBoundingClientRect().top + parseFloat(mark.marginTop);
const size = parseFloat(getComputedStyle(item).fontSize);
const shown = mark.display !== 'none' && mark.visibility === 'visible' && parseFloat(mark.opacity) > 0;
// A laid-out box pushes the text 1.5em in.
const inset = (item.querySelector('p').getBoundingClientRect().left - item.getBoundingClientRect().left) / size;
const drawn = shown && mark.content !== 'none' && parseFloat(mark.borderTopWidth) > 0 && Math.abs(inset - 1.5) < 0.05;
return [parseFloat(mark.width) / size, parseFloat(mark.height) / size, top + parseFloat(mark.height) / 2 - (line.top + line.height / 2), drawn];
}),
cells: view.querySelectorAll('.milkdown td').length,
notes: view.querySelectorAll('[data-type="footnote_definition"]').length,
blocks,
+8
View File
@@ -29,6 +29,14 @@ impl Desk {
return;
}
tab.extra.heard = changed;
self.save_after_pause(id, cx);
}
/// Counts unsaved text as an edit; saves after the pause.
pub fn save_after_pause(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.extra.policy.edit(Instant::now());
cx.spawn(async move |desk, cx| {
let wait = DEBOUNCE + Duration::from_millis(50);
+3 -3
View File
@@ -2,7 +2,7 @@
use std::rc::Rc;
use ely_files::{Fingerprint, Policy, PolicyEngine};
use ely_files::Fingerprint;
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
git::{self as merging, Region, Take, ThreeWayMerge},
@@ -102,7 +102,7 @@ impl Desk {
Choice::Mine => {
if let Some(tab) = self.tab_mut(id) {
tab.session.rebase(fingerprint);
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
tab.extra.restart_autosave();
}
self.set_outside(id, Outside::Clear, cx);
self.save(id, None, window, cx);
@@ -120,7 +120,7 @@ impl Desk {
return self.set_outside(id, Outside::Unread(why), cx);
}
tab.extra.base = disk.text.clone();
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
tab.extra.restart_autosave();
tab.doc.lock().expect("the text lock").source = disk;
let view = tab.view.clone();
view.update(cx, |view, cx| view.adopt(merged, cx));
+6 -5
View File
@@ -81,6 +81,11 @@ impl Host for Desk {
&mut self.core
}
/// A quit records the open tabs first.
fn quitting(&mut self, cx: &mut Context<Self>) {
self.leave(cx);
}
/// A quit or close stopped: the kept list follows again.
fn close_cancelled(&mut self, cx: &mut Context<Self>) {
self.keeping.leaving = false;
@@ -215,7 +220,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let asked = ely_ui::launch_files(cx);
desk.update(cx, |desk, cx| {
desk.core.root = root.downgrade();
desk.core.window = Some(window.window_handle());
desk.own_window(window, cx);
window.focus(&desk.core.focus, cx);
// Launch files stand alone; unsaved text still returns.
if asked.is_empty() {
@@ -403,10 +408,6 @@ impl Render for Desk {
.on_action(cx.listener(Self::on_export))
.on_action(cx.listener(Self::on_print))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(|desk, quit, window, cx| {
desk.leave(cx);
desk.on_quit(quit, window, cx)
}))
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table("insert", cx)))
.on_action(cx.listener(|desk, _: &RowAbove, _, cx| desk.table("row-above", cx)))
.on_action(cx.listener(|desk, _: &RowBelow, _, cx| desk.table("row-below", cx)))
+86 -4
View File
@@ -55,6 +55,14 @@ pub struct Extra {
pub restored: bool,
}
impl Extra {
/// Autosave from scratch; a write in flight no longer counts.
pub fn restart_autosave(&mut self) {
self.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
self.writing = None;
}
}
impl Default for Extra {
fn default() -> Self {
Extra {
@@ -217,11 +225,26 @@ impl Desk {
Ok(read) => read,
Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx),
};
// Keys the page has not sent yet count first.
if !self.settled(id, cx) {
return self.when_settled(id, move |desk, _, cx| desk.disk_read(id, path, Ok((fingerprint, disk)), cx));
}
let current = tab.doc.lock().expect("the text lock").current.clone();
let kept = disk.text == tab.extra.base;
let dirty = tab.dirty(cx);
log::info!("mark: {} changed outside; unsaved {dirty}", path.display());
match (disk.text == current, dirty) {
(true, _) | (false, false) => self.take_disk(id, disk, fingerprint, cx),
// The same text: only the file underneath is new.
(true, _) => {
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
// What shows is now saved: the tab is clean.
let saved = crate::engine::mark(&disk.text);
view.update(cx, |view, cx| view.saved(&saved, cx));
}
}
// The text as last saved: the edits stay unsaved.
(false, true) if kept => self.keep_edits(id, disk, fingerprint, cx),
(false, false) => self.take_disk(id, disk, fingerprint, cx),
(false, true) => {
let tab = self.tab_mut(id).expect("open");
tab.session.external_modified();
@@ -247,14 +270,47 @@ impl Desk {
fingerprint: Fingerprint,
cx: &mut Context<Self>,
) {
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
view.update(cx, |view, cx| view.refresh(disk, cx));
}
}
/// A new file with the saved text; edits stay.
fn keep_edits(&mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.session.rebase(fingerprint);
tab.doc.lock().expect("the text lock").source = disk;
// A conflict stopped autosave, maybe mid-write; it resumes.
tab.extra.restart_autosave();
let (registry, path) = (tab.registry, tab.session.path().map(Path::to_path_buf));
if let (Some(registry), Some(path)) = (registry, path)
&& let Err(error) = self.core.sessions.track(registry, &path)
{
log::warn!("mark: the replaced file did not register: {error}");
}
self.set_outside(id, Outside::Clear, cx);
self.save_after_pause(id, cx);
}
/// The tab takes the file as saved; returns its view.
fn adopt_disk(
&mut self,
id: u64,
disk: &Source,
fingerprint: Fingerprint,
cx: &mut Context<Self>,
) -> Option<gpui::Entity<crate::view::MarkView>> {
let tab = self.tab_mut(id)?;
if let Err(error) = tab.session.reload_at(fingerprint) {
return self.set_outside(id, Outside::Unread(error.to_string()), cx);
self.set_outside(id, Outside::Unread(error.to_string()), cx);
return None;
}
tab.extra.base = disk.text.clone();
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
tab.extra.restart_autosave();
// Its bytes (BOM, line ends) are what saves keep now.
tab.doc.lock().expect("the text lock").source = disk.clone();
let (view, registry) = (tab.view.clone(), tab.registry);
let path = tab.session.path().map(Path::to_path_buf);
// An atomic replace is a new file underneath.
@@ -263,8 +319,8 @@ impl Desk {
{
log::warn!("mark: the replaced file did not register: {error}");
}
view.update(cx, |view, cx| view.refresh(disk, cx));
self.set_outside(id, Outside::Clear, cx);
Some(view)
}
pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context<Self>) {
@@ -345,3 +401,29 @@ fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<Recommended
log::info!("mark: watching {} folders", dirs.len());
Ok(watcher)
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use ely_files::Action;
use super::Extra;
#[test]
fn a_restarted_autosave_saves_the_next_edit() {
let mut extra = Extra::default();
extra.policy.set_has_path();
extra.policy.edit(Instant::now());
// A write was in flight when the disk changed.
extra.writing = Some(1);
extra.restart_autosave();
// As autosave settles a heard write, then edits.
if let Some(revision) = extra.writing.take() {
extra.policy.save_finished(revision, true);
}
let now = Instant::now();
extra.policy.edit(now);
assert!(matches!(extra.policy.tick(now + Duration::from_secs(5)), Action::SaveDue(_)));
}
}
+12 -2
View File
@@ -116,12 +116,22 @@ a {
list-style: none;
}
/* A text-sized box, centred on the first line. */
.milkdown li[data-item-type='task']::before {
content: '☐ ';
content: '';
flex: none;
box-sizing: border-box;
width: 1em;
height: 1em;
margin-top: calc(0.25em + (1lh - 1em) / 2);
border: 0.09em solid var(--ely-fg-muted);
border-radius: 0.2em;
}
.milkdown li[data-item-type='task'][data-checked='true']::before {
content: '☑ ';
border-color: var(--ely-link);
background: var(--ely-link);
box-shadow: inset 0 0 0 0.18em var(--ely-bg);
}
.milkdown hr {
+7 -2
View File
@@ -1,5 +1,6 @@
// Blocks Milkdown cannot write back stay raw text (§14.3).
import { editorViewCtx, remarkCtx, schemaCtx, serializerCtx } from '@milkdown/kit/core'
import { canon } from './tables.js'
import { Fragment } from '@milkdown/kit/prose/model'
import { Selection } from '@milkdown/kit/prose/state'
import { $nodeSchema } from '@milkdown/kit/utils'
@@ -51,6 +52,11 @@ export function faithful(ctx, original) {
const schema = ctx.get(schemaCtx)
const serializer = ctx.get(serializerCtx)
const written = (nodes) => serializer(schema.topNodeType.create(null, nodes)).replace(/\n+$/, '')
// Table spacing is free; every cell's source must match.
const kept = (node, text) => {
const own = written([node])
return own === text || canon(own) === canon(text)
}
const rawOf = ([from, to]) => {
const text = original.slice(from, to)
return schema.nodes.ely_raw.create(null, text ? schema.text(text) : null)
@@ -68,8 +74,7 @@ export function faithful(ctx, original) {
if (!blank)
parsed.forEach((node, _, index) => {
gap(last, at[index][0])
const exact = written([node]) === original.slice(...at[index])
nodes.push(exact ? node : rawOf(at[index]))
nodes.push(kept(node, original.slice(...at[index])) ? node : rawOf(at[index]))
places.push(at[index])
last = at[index][1]
})
+14 -2
View File
@@ -1,16 +1,17 @@
// The document's own markers, so blocks write back unchanged.
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import { visit } from 'unist-util-visit'
/** remark-stringify options most of `text` already uses. */
/** Writing options most of `text` already uses; tables' for remark-gfm. */
export function style(text) {
const votes = {}
const vote = (key, value) => {
votes[key] ??= new Map()
votes[key].set(value, (votes[key].get(value) ?? 0) + 1)
}
visit(unified().use(remarkParse).parse(text), (node) => {
visit(unified().use(remarkParse).use(remarkGfm).parse(text), (node) => {
const at = node.position?.start.offset
if (at === undefined) return
const head = text.slice(at, at + 12)
@@ -34,6 +35,17 @@ export function style(text) {
case 'heading':
if (node.depth <= 2) vote('setext', head[0] !== '#')
break
case 'table': {
// Row nodes leave out quote and list prefixes.
const rows = node.children.map((row) => text.slice(row.position.start.offset, row.position.end.offset).trim())
const padded = /\| /.test(rows[0])
const cells = rows.flatMap((row) => row.replace(/^\||\|$/g, '').split('|'))
const inner = (cell) => (padded ? cell.replace(/^ | $/g, '') : cell)
vote('tableCellPadding', padded)
// Aligned cells carry spaces beyond the padding.
vote('tablePipeAlign', cells.some((cell) => /^\s|\s$/.test(inner(cell))))
break
}
}
})
const most = (key) => [...votes[key].entries()].sort((a, b) => b[1] - a[1])[0][0]
+35
View File
@@ -0,0 +1,35 @@
// Tables compare by alignment and cell source, not spacing.
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import { SKIP, visit } from 'unist-util-visit'
const reader = unified().use(remarkParse).use(remarkGfm)
/** A cell's own source, without its padding. */
function cell(text, node) {
const kids = node.children
return kids.length ? text.slice(kids[0].position.start.offset, kids.at(-1).position.end.offset) : ''
}
/** `text` with each table's spacing made canonical. */
export function canon(text) {
const tables = []
visit(reader.parse(text), 'table', (node) => {
tables.push(node)
return SKIP
})
let out = text
// Later tables first, so earlier offsets hold.
for (const table of tables.reverse()) {
const width = table.children[0].children.length
// GFM fills a short row with empty cells.
const rows = table.children.map((row) => {
const cells = row.children.map((node) => cell(text, node))
return [...cells, ...Array(Math.max(0, width - cells.length)).fill('')].join('|')
})
const shape = [table.align.join(','), ...rows].join('\n')
out = `${out.slice(0, table.position.start.offset)}\u0000${shape}\u0000${out.slice(table.position.end.offset)}`
}
return out
}
+4 -2
View File
@@ -19,7 +19,7 @@ import { keymap } from '@milkdown/kit/prose/keymap'
import { liftListItem, sinkListItem } from '@milkdown/kit/prose/schema-list'
import { redo, redoDepth, undoDepth } from '@milkdown/kit/prose/history'
import { $prose, callCommand } from '@milkdown/kit/utils'
import { insertTableCommand } from '@milkdown/kit/preset/gfm'
import { insertTableCommand, remarkGFMPlugin } from '@milkdown/kit/preset/gfm'
import {
addColumnAfter,
addColumnBefore,
@@ -87,7 +87,9 @@ export async function visual(root, markdown, editable, texts, changed, edge) {
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, markdown)
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...style(markdown) }))
const { tableCellPadding, tablePipeAlign, ...marks } = style(markdown)
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...marks }))
ctx.set(remarkGFMPlugin.options.key, { tableCellPadding, tablePipeAlign })
ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, editable: () => editable }))
})
.use(commonmark)
+3 -2
View File
@@ -12,7 +12,7 @@ use crate::pages::{rect_on, to_view};
use crate::view::PdfView;
/// How far a click may move, in window pixels.
const CLICK: f32 = 3.0;
pub(crate) const CLICK: f32 = 3.0;
/// Pages read for their annotations at once.
const BATCH: usize = 20;
@@ -253,8 +253,9 @@ impl PdfView {
if annots.is_empty() {
return;
}
let author = ely_ui::settings::current(cx).author.clone();
self.settled_edit(what, cx, move |worker, doc| {
let name = ely_platform::user::name()?;
let name = ely_platform::user::named(&author)?;
annots.iter_mut().for_each(|annot| annot.author = name.clone());
worker.annotate(doc, &annots)
});
+4 -5
View File
@@ -72,7 +72,7 @@ impl Viewer {
let turned = unturn(margins, view.read(cx).rotate);
let points = turned.map(|side| side * MM);
view.update(cx, |view, cx| {
view.edit("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
view.edit_pages("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
});
cx.notify();
}
@@ -136,7 +136,7 @@ impl Viewer {
}
fn failed_write(&self, name: &str, error: &io::Error, cx: &mut App) {
if error.kind() == io::ErrorKind::PermissionDenied {
if ely_pdf_engine::refused(error).is_some() {
return self.failed(error, cx);
}
let text = Self::text(cx, "office.error.save", &[("name", name), ("error", &error.to_string())]);
@@ -183,7 +183,7 @@ impl Viewer {
written.map(|()| targets.len())
},
move |viewer, done, _, cx| match done {
Ok(count) => viewer.toast_output(Self::text(cx, "pdf.split.done", &[("count", &count.to_string())]), opened.clone(), cx),
Ok(count) => viewer.toast_output(ely_ui::i18n::counted(cx, "pdf.split.done", count as u64, &[("count", &count.to_string())]), opened.clone(), cx),
Err(error) => viewer.failed_write(&stem, &error, cx),
},
cx,
@@ -205,8 +205,7 @@ impl Viewer {
};
let card = match self.ask.as_ref()? {
Ask::Crop { pages, margins, .. } => {
let count = pages.len().to_string();
let title = Self::text(cx, "pdf.crop.title", &[("count", &count)]);
let title = ely_ui::i18n::counted(cx, "pdf.crop.title", pages.len() as u64, &[("count", &pages.len().to_string())]);
let sides = ["top", "right", "bottom", "left"];
let rows = sides.iter().enumerate().fold(DescriptionList::new(), |rows, (at, side)| {
let name = label(&format!("pdf.crop.{side}"));
+132
View File
@@ -0,0 +1,132 @@
//! Old tiles stay under a page until new ones land.
use std::sync::Arc;
use gpui::RenderImage;
use crate::layout::{Placed, visible};
use crate::pages::{TILE, TileKey};
use crate::view::PdfView;
/// A tile no longer current and its page's size then.
pub struct Old {
pub key: TileKey,
pub image: Arc<RenderImage>,
pub size: (f32, f32),
}
/// A page on screen: place, device size, tiles in view.
pub struct Showing {
pub place: Placed,
pub device: (i32, i32),
pub keys: Vec<(TileKey, [i32; 4])>,
}
/// Where `key`'s tile sat in its page, as fractions.
fn fraction(key: &TileKey, size: (f32, f32)) -> [f32; 4] {
let (w, h) = match key.rotate.rem_euclid(180) {
90 => (size.1, size.0),
_ => size,
};
let scale = key.scale as f32 / 1000.0;
let (dw, dh) = ((w * scale).ceil(), (h * scale).ceil());
let (x, y) = ((key.tx * TILE) as f32, (key.ty * TILE) as f32);
[x / dw, y / dh, (TILE as f32).min(dw - x) / dw, (TILE as f32).min(dh - y) / dh]
}
impl PdfView {
/// The pages in view and the tiles each needs.
pub fn showing(&self, sf: f32, dark: bool) -> Vec<Showing> {
let laid = self.laid_out();
let (room_w, room_h) = (f32::from(self.room.size.width), f32::from(self.room.size.height));
let (left, top) = (-f32::from(self.scroll.offset().x), -f32::from(self.scroll.offset().y));
let scale = (laid.scale * sf * 1000.0).round() as u32;
visible(&laid, top, top + room_h)
.filter(|p| p.x < left + room_w && p.x + p.w > left)
.map(|place| {
let (dw, dh) = ((place.w * sf).ceil() as i32, (place.h * sf).ceil() as i32);
let ys = crate::pages::span(top - place.y, top + room_h - place.y, sf, dh);
let xs = crate::pages::span(left - place.x, left + room_w - place.x, sf, dw);
let keys = ys
.flat_map(|ty| xs.clone().map(move |tx| (tx, ty)))
.map(|(tx, ty)| {
let key = TileKey { page: place.page, scale, rotate: self.rotate, dark, tx, ty };
(key, [tx * TILE, ty * TILE, TILE.min(dw - tx * TILE), TILE.min(dh - ty * TILE)])
})
.collect();
Showing { place: *place, device: (dw, dh), keys }
})
.collect()
}
/// What a page draws, bottom first, in page fractions.
pub fn pieces(&self, showing: &Showing) -> Vec<(Arc<RenderImage>, [f32; 4])> {
let (dw, dh) = (showing.device.0 as f32, showing.device.1 as f32);
let current = showing.keys.iter().filter_map(|(key, rect)| {
let at = [rect[0] as f32 / dw, rect[1] as f32 / dh, rect[2] as f32 / dw, rect[3] as f32 / dh];
self.tiles.get(key).map(|image| (image.clone(), at))
});
if showing.keys.iter().all(|(key, _)| self.tiles.contains_key(key)) {
return current.collect();
}
// Old tiles of this page fill in underneath.
let page = showing.place.page;
let size = self.sizes[page];
let scale = showing.keys.first().map(|(key, _)| key.scale);
let rescaled = self
.tiles
.iter()
.filter(|(key, _)| key.page == page && key.rotate == self.rotate && Some(key.scale) != scale)
.map(|(key, image)| (image.clone(), fraction(key, size)));
let kept = self
.backdrop
.iter()
.filter(|old| old.key.page == page && old.key.rotate == self.rotate && old.size == size)
.map(|old| (old.image.clone(), fraction(&old.key, old.size)));
kept.chain(rescaled).chain(current).collect()
}
/// Keeps every tile under its page, as before an edit.
pub fn keep_all(&mut self) {
let sizes = &self.sizes;
let old = self.tiles.drain().map(|(key, image)| Old { key, image, size: sizes[key.page] });
self.backdrop.extend(old);
}
/// Moves unseen tiles under their page; drops covered ones.
pub fn settle(&mut self, showing: &[Showing]) {
let shown = |page: usize| showing.iter().find(|s| s.place.page == page);
let seen = |key: &TileKey| shown(key.page).is_some_and(|s| s.keys.iter().any(|(k, _)| k == key));
let leaving: Vec<TileKey> = self.tiles.keys().filter(|key| !seen(key)).copied().collect();
for key in leaving {
let image = self.tiles.remove(&key).expect("a tile just listed");
match shown(key.page) {
Some(_) => self.backdrop.push(Old { key, image, size: self.sizes[key.page] }),
None => self.retired.push(image),
}
}
let covered = |page: usize| shown(page).is_none_or(|s| s.keys.iter().all(|(key, _)| self.tiles.contains_key(key)));
let (sizes, rotate) = (&self.sizes, self.rotate);
let (gone, kept): (Vec<Old>, Vec<Old>) = std::mem::take(&mut self.backdrop).into_iter().partition(|old| {
covered(old.key.page) || old.key.rotate != rotate || sizes.get(old.key.page) != Some(&old.size)
});
self.backdrop = kept;
for old in gone {
self.retired.push(old.image);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_tile_keeps_its_place_as_a_fraction_of_its_page() {
let key = TileKey { page: 0, scale: 2000, rotate: 0, dark: false, tx: 1, ty: 0 };
// A 612 by 792 page at 2x: 1224 by 1584.
assert_eq!(fraction(&key, (612.0, 792.0)), [512.0 / 1224.0, 0.0, 512.0 / 1224.0, 512.0 / 1584.0]);
let turned = TileKey { rotate: 90, ..key };
assert_eq!(fraction(&turned, (612.0, 792.0)), [512.0 / 1584.0, 0.0, 512.0 / 1584.0, 512.0 / 1224.0]);
}
}
+24 -7
View File
@@ -76,6 +76,10 @@ pub struct Drag {
pub from: (f32, f32),
pub now: (f32, f32),
pub scale: bool,
/// Where the press landed, in window pixels.
pub pressed: (f32, f32),
/// The pointer went past a click's slack.
pub moved: bool,
}
/// The corner handle's reach, in page points.
@@ -107,6 +111,11 @@ fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool {
x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]
}
/// Whether `outer` holds all of `inner`.
fn covers(outer: &[f32; 4], inner: &[f32; 4]) -> bool {
outer[0] <= inner[0] && outer[1] <= inner[1] && outer[2] >= inner[2] && outer[3] >= inner[3]
}
impl PdfView {
/// A drag or drawing is under way.
pub fn gesturing(&self) -> bool {
@@ -174,16 +183,21 @@ impl PdfView {
Some(Aim::Object { page: on, index }) if on == page => layer.drawn.get(index).filter(|d| corner(&d.rect)).map(|_| (index, true)),
_ => None,
};
let hit = held.or_else(|| layer.drawn.iter().rposition(|d| inside(&d.rect, point)).map(|index| (index, false)));
let text = layer.blocks.iter().position(|b| b.editable && inside(&b.rect, point));
// Text wins over an object drawn all around it.
let around = |rect: &[f32; 4]| text.is_some_and(|t| covers(rect, &layer.blocks[t].rect));
let hit = held.or_else(|| {
let object = layer.drawn.iter().rposition(|d| inside(&d.rect, point) && !around(&d.rect));
object.map(|index| (index, false))
});
self.aim = match hit {
Some((index, scale)) => {
let rect = layer.drawn[index].rect;
self.drag = Some(Drag { page, index, rect, from: point, now: point, scale });
let pressed = (f32::from(at.x), f32::from(at.y));
self.drag = Some(Drag { page, index, rect, from: point, now: point, scale, pressed, moved: false });
Some(Aim::Object { page, index })
}
None => (layer.blocks.iter())
.position(|b| b.editable && inside(&b.rect, point))
.map(|index| Aim::Text { page, index }),
None => text.map(|index| Aim::Text { page, index }),
};
cx.notify();
}
@@ -193,6 +207,8 @@ impl PdfView {
return;
};
if let Some(drag) = self.drag.as_mut().filter(|drag| drag.page == page) {
let far = (f32::from(at.x) - drag.pressed.0).abs() + (f32::from(at.y) - drag.pressed.1).abs();
drag.moved |= far >= crate::annotate::CLICK;
drag.now = point;
cx.notify();
}
@@ -203,7 +219,8 @@ impl PdfView {
let Some(drag) = self.drag.take() else {
return;
};
if (drag.now.0 - drag.from.0).abs() < 1.0 && (drag.now.1 - drag.from.1).abs() < 1.0 {
// A hand that shakes a little still clicks.
if !drag.moved {
return cx.notify();
}
// A queued edit may renumber what was dragged.
@@ -264,7 +281,7 @@ mod tests {
#[test]
fn a_drag_moves_or_scales_from_the_top_left() {
let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false };
let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false, pressed: (0.0, 0.0), moved: true };
assert_eq!(drag.change(), [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]);
assert_eq!(drag.shown(), [20.0, 25.0, 120.0, 75.0]);
drag = Drag { from: (110.0, 70.0), now: (210.0, 80.0), scale: true, ..drag };
+36 -12
View File
@@ -17,6 +17,8 @@ pub struct Queued {
op: Op,
pick: Option<BTreeSet<usize>>,
doc: Doc,
/// The edit may add, drop, move or reshape pages.
pages: bool,
}
/// The document as an edit left it.
@@ -36,20 +38,31 @@ impl PdfView {
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
self.edit_picking(what, None, op, cx);
self.queue_edit(what, None, false, op, cx);
}
/// Queues an edit to the pages themselves.
pub fn edit_pages(
&mut self,
what: &'static str,
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
self.queue_edit(what, None, true, op, cx);
}
/// Queues `op`; once it lands, `pick` is picked.
pub fn edit_picking(
pub fn queue_edit(
&mut self,
what: &'static str,
pick: Option<BTreeSet<usize>>,
pages: bool,
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
log::info!("pdf: queues {what}");
// Aimed at what is shown now; later states refuse it.
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc });
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages });
self.next_edit(cx);
}
@@ -57,7 +70,7 @@ impl PdfView {
if self.editing {
return;
}
let Some(Queued { what, op, pick, doc }) = self.queue.pop_front() else {
let Some(Queued { what, op, pick, doc, pages }) = self.queue.pop_front() else {
return;
};
self.editing = true;
@@ -73,7 +86,7 @@ impl PdfView {
view.editing = false;
match fresh {
Ok(fresh) => {
view.refresh(fresh, cx);
view.refresh(fresh, pages, cx);
if let Some(pick) = pick {
view.anchor = pick.first().copied();
view.picked = pick;
@@ -93,19 +106,30 @@ impl PdfView {
}
/// Everything read before the edit goes.
fn refresh(&mut self, fresh: Fresh, cx: &mut Context<Self>) {
fn refresh(&mut self, fresh: Fresh, pages: bool, cx: &mut Context<Self>) {
self.version += 1;
self.edited += 1;
(self.doc, self.mark) = (fresh.doc, fresh.mark);
self.sizes = sizes(fresh.pages, &fresh.first);
let measured = sizes(fresh.pages, &fresh.first);
if pages || fresh.pages != self.sizes.len() {
// A page may now stand where another stood.
self.retire_tiles();
self.sizes = measured;
self.thumbs_stale.clear();
for (_, image) in self.thumbs.drain() {
self.retired.push(image);
}
} else {
// Same pages: old pictures stay until repainted.
self.keep_all();
self.thumbs_stale.extend(self.thumbs.keys().copied());
let read = fresh.first.len();
self.sizes[..read].copy_from_slice(&measured[..read]);
}
self.marks = fresh.marks;
self.retire_tiles();
self.failed.clear();
self.text.clear();
self.asking_text.clear();
for (_, image) in self.thumbs.drain() {
self.retired.push(image);
}
self.thumbs_failed.clear();
// Indices shift with any edit; aims start over.
(self.layers, self.aim, self.drag) = (Default::default(), None, None);
@@ -144,6 +168,6 @@ impl PdfView {
pub fn undo(&mut self, redo: bool, cx: &mut Context<Self>) {
let what = if redo { "redo" } else { "undo" };
self.edit(what, move |worker, doc| worker.undo(doc, redo), cx);
self.edit_pages(what, move |worker, doc| worker.undo(doc, redo), cx);
}
}
+1
View File
@@ -1,6 +1,7 @@
mod annotate;
mod ask;
mod attach;
mod backdrop;
mod content;
mod document;
mod editing;
+1 -1
View File
@@ -161,7 +161,7 @@ impl Viewer {
cx.notify();
let pages: Vec<(usize, Vec<OcrLine>)> = run.read.into_iter().filter(|(_, lines)| replace || !lines.is_empty()).collect();
let (read, skipped) = (pages.len().to_string(), run.skipped.to_string());
let summary = Self::text(cx, "pdf.ocr.done", &[("read", &read), ("skipped", &skipped)]);
let summary = ely_ui::i18n::counted(cx, "pdf.ocr.done", pages.len() as u64, &[("read", &read), ("skipped", &skipped)]);
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else {
return self.toast(summary, Severity::Info, cx);
};
+5 -5
View File
@@ -81,7 +81,7 @@ impl PdfView {
let pages = self.chosen_pages();
let before = pages.iter().filter(|page| **page < to).count();
let landed = (to - before..to - before + pages.len()).collect();
self.edit_picking("moving pages", Some(landed), move |worker, doc| worker.move_pages(doc, &pages, to), cx);
self.queue_edit("moving pages", Some(landed), true, move |worker, doc| worker.move_pages(doc, &pages, to), cx);
}
}
@@ -105,14 +105,14 @@ impl Viewer {
}
view.update(cx, |view, cx| {
view.picked.clear();
view.edit("deleting pages", move |worker, doc| worker.delete_pages(doc, &pages), cx);
view.edit_pages("deleting pages", move |worker, doc| worker.delete_pages(doc, &pages), cx);
});
}
fn turn_pages(&mut self, by: i32, cx: &mut Context<Self>) {
self.on_pages(cx, |view, cx| {
let pages = view.chosen_pages();
view.edit("turning pages", move |worker, doc| worker.rotate_pages(doc, &pages, by), cx);
view.edit_pages("turning pages", move |worker, doc| worker.rotate_pages(doc, &pages, by), cx);
});
}
@@ -136,7 +136,7 @@ impl Viewer {
self.on_pages(cx, |view, cx| {
let at = view.chosen_pages().last().copied().expect("one page at least");
let size = view.sizes[at];
view.edit("inserting a page", move |worker, doc| worker.insert_blank(doc, at + 1, size), cx);
view.edit_pages("inserting a page", move |worker, doc| worker.insert_blank(doc, at + 1, size), cx);
});
}
@@ -148,7 +148,7 @@ impl Viewer {
self.pick(false, cx, |viewer, path: PathBuf, cx| {
viewer.on_pages(cx, |view, cx| {
let at = view.chosen_pages().last().copied().expect("one page at least") + 1;
view.edit(
view.edit_pages(
"inserting pages",
move |worker, doc| worker.insert_file(doc, at, &path).map(|(_, mark)| mark),
cx,
+1 -1
View File
@@ -177,7 +177,7 @@ impl Viewer {
},
move |viewer, done, _, cx| match done {
Ok(count) => {
let text = Self::text(cx, "pdf.export.images_done", &[("count", &count.to_string())]);
let text = ely_ui::i18n::counted(cx, "pdf.export.images_done", count as u64, &[("count", &count.to_string())]);
match folder.clone() {
Some(folder) => viewer.toast_output(text, folder, cx),
None => viewer.toast(text, Severity::Success, cx),
+41 -57
View File
@@ -1,6 +1,6 @@
//! Only the pages on screen, drawn in tiles (PDF-01, PERF-05).
use std::{collections::HashSet, sync::Arc};
use std::sync::Arc;
use ely_gpui_component::theme::ActiveTheme as _;
use gpui::{
@@ -10,7 +10,7 @@ use gpui::{
};
use image::{Frame, ImageBuffer, Rgba};
use crate::layout::{GAP, Layout, Mode, Placed, layout, visible};
use crate::layout::{GAP, Layout, Mode, Placed, layout};
use crate::view::{PdfView, Reported};
/// A tile's side in device pixels.
@@ -58,7 +58,7 @@ pub fn rect_on(rect: [f32; 4], size: (f32, f32), rotate: i32, scale: f32) -> (f3
}
/// Tiles covering `[from, to)` view pixels of a `side`-pixel page.
fn span(from: f32, to: f32, sf: f32, side: i32) -> std::ops::RangeInclusive<i32> {
pub(crate) fn span(from: f32, to: f32, sf: f32, side: i32) -> std::ops::RangeInclusive<i32> {
let first = (from.max(0.0) * sf).floor() as i32 / TILE;
let last = ((to * sf).ceil() as i32 - 1).min(side - 1).max(0) / TILE;
first..=last
@@ -69,11 +69,13 @@ fn drawn_tile(image: Arc<RenderImage>, page: usize, view: gpui::Entity<PdfView>)
canvas(
|_, _, _| {},
move |bounds, _, window, cx| match window.paint_image(bounds, bounds, Default::default(), image, 0, false) {
Ok(()) if !view.read(cx).drawn => {
view.update(cx, |view, _| view.drawn = true);
log::info!("pdf: first tile drawn, page {page}");
}
Ok(()) => {}
Ok(()) => view.update(cx, |view, _| {
let area = f64::from(f32::from(bounds.size.width) * f32::from(bounds.size.height));
if view.painted == 0.0 && area > 0.0 {
log::info!("pdf: first tile drawn, page {page}");
}
view.painted += area;
}),
Err(error) => log::error!("pdf: drawing a page failed: {error}"),
},
)
@@ -127,18 +129,14 @@ impl PdfView {
(colors.sunken, if dark { colors.surface } else { colors.paper })
};
let laid = self.laid_out();
let (room_w, room_h) = (f32::from(self.room.size.width), f32::from(self.room.size.height));
if let Some((x, y)) = self.go_to.take() {
self.scroll.set_offset(point(px(-x), px(-y)));
}
let (left, top) = (-f32::from(self.scroll.offset().x), -f32::from(self.scroll.offset().y));
let shown: Vec<Placed> = visible(&laid, top, top + room_h)
.filter(|p| p.x < left + room_w && p.x + p.w > left)
.copied()
.collect();
let top = -f32::from(self.scroll.offset().y);
let showing = self.showing(sf, dark);
// The current page: first showing more than a sliver.
if let Some(first) = shown.iter().find(|p| p.y + p.h > top + GAP * 3.0) {
let paired = shown.iter().any(|p| p.page == self.current) && first.page / 2 == self.current / 2;
if let Some(first) = showing.iter().map(|s| s.place).find(|p| p.y + p.h > top + GAP * 3.0) {
let paired = showing.iter().any(|s| s.place.page == self.current) && first.page / 2 == self.current / 2;
let now = match self.mode {
Mode::Single => self.current,
// Either page of a pair in view may be current.
@@ -150,47 +148,33 @@ impl PdfView {
cx.emit(Reported);
}
}
let scale = (laid.scale * sf * 1000.0).round() as u32;
let (mut wanted, mut pages, mut seen) = (Vec::new(), Vec::new(), HashSet::new());
for place in &shown {
let (dw, dh) = ((place.w * sf).ceil() as i32, (place.h * sf).ceil() as i32);
let (ys, xs) = (
span(top - place.y, top + room_h - place.y, sf, dh),
span(left - place.x, left + room_w - place.x, sf, dw),
);
let mut tiles = Vec::new();
for ty in ys {
for tx in xs.clone() {
let key = TileKey { page: place.page, scale, rotate: self.rotate, dark, tx, ty };
let rect = [tx * TILE, ty * TILE, TILE.min(dw - tx * TILE), TILE.min(dh - ty * TILE)];
seen.insert(key);
match self.tiles.get(&key) {
Some(image) => tiles.push(
drawn_tile(image.clone(), place.page, cx.entity())
.absolute()
.left(px(rect[0] as f32 / sf))
.top(px(rect[1] as f32 / sf))
.w(px(rect[2] as f32 / sf))
.h(px(rect[3] as f32 / sf))
.into_any_element(),
),
None if !self.failed.contains(&key) => wanted.push((key, rect)),
None => {}
}
}
}
pages.push((*place, tiles));
}
// Tiles out of view go.
let retired = &mut self.retired;
self.tiles.retain(|key, image| {
let kept = seen.contains(key);
if !kept {
retired.push(image.clone());
}
kept
});
let pages_shown: Vec<usize> = shown.iter().map(|place| place.page).collect();
let wanted: Vec<(TileKey, [i32; 4])> = showing
.iter()
.flat_map(|s| s.keys.iter().copied())
.filter(|(key, _)| !self.tiles.contains_key(key) && !self.failed.contains(key))
.collect();
let pages: Vec<(Placed, Vec<AnyElement>)> = showing
.iter()
.map(|s| {
let (w, h) = (s.place.w, s.place.h);
let tiles = self
.pieces(s)
.into_iter()
.map(|(image, at)| {
drawn_tile(image, s.place.page, cx.entity())
.absolute()
.left(px(at[0] * w))
.top(px(at[1] * h))
.w(px(at[2] * w))
.h(px(at[3] * h))
.into_any_element()
})
.collect();
(s.place, tiles)
})
.collect();
self.settle(&showing);
let pages_shown: Vec<usize> = showing.iter().map(|s| s.place.page).collect();
self.ask_layers(&pages_shown, cx);
self.ask_notes(pages_shown.iter().copied(), cx);
self.paint(wanted, cx);
+3 -3
View File
@@ -46,9 +46,9 @@ fn masked(window: &mut Window, cx: &mut Context<Viewer>) -> Entity<TextInput> {
impl Viewer {
/// A refusal names what is missing.
pub(crate) fn failed(&self, error: &io::Error, cx: &mut App) {
match error.kind() {
io::ErrorKind::PermissionDenied => self.toast(crate::view::restricted(&error.to_string(), cx), Severity::Warning, cx),
_ => self.panel_failed(&error.to_string(), cx),
match ely_pdf_engine::refused(error) {
Some(needed) => self.toast(crate::view::restricted(needed, cx), Severity::Warning, cx),
None => self.panel_failed(&error.to_string(), cx),
}
}
+3 -3
View File
@@ -185,7 +185,7 @@ impl Viewer {
},
move |viewer, done, _, cx| match done {
Ok(0) => viewer.toast_output(Self::text(cx, "pdf.clean.done", &[("name", &shown)]), opened.clone(), cx),
Ok(n) => viewer.toast_output(Self::text(cx, "pdf.redact.done", &[("name", &shown), ("count", &n.to_string())]), opened.clone(), cx),
Ok(n) => viewer.toast_output(ely_ui::i18n::counted(cx, "pdf.redact.done", n as u64, &[("name", &shown), ("count", &n.to_string())]), opened.clone(), cx),
Err(error) => viewer.failed(&error, cx),
},
cx,
@@ -200,8 +200,8 @@ impl Viewer {
let (title, body) = match removal {
Removal::Clean { reach, what, .. } => ("pdf.clean.title", self.clean_rows(reach, *what, &this, cx)),
Removal::Redact { regions, .. } => {
let count = regions.len().to_string();
("pdf.redact.title", div().child(Self::text(cx, "pdf.redact.count", &[("count", &count)])).into_any_element())
let count = ely_ui::i18n::counted(cx, "pdf.redact.count", regions.len() as u64, &[("count", &regions.len().to_string())]);
("pdf.redact.title", div().child(count).into_any_element())
}
};
let title = Self::text(cx, title, &[("name", &name)]);
+13 -4
View File
@@ -1,6 +1,6 @@
//! Page pictures, and bookmarks if any (PDF-01, PDF-02).
use std::{collections::BTreeSet, ops::Range};
use std::{collections::BTreeSet, ops::Range, sync::Arc};
use ely_gpui_component::{
buttons::SegmentedControl,
@@ -9,7 +9,7 @@ use ely_gpui_component::{
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
Action, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, Role,
Action, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, RenderImage, Role,
StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, img, prelude::FluentBuilder as _, px,
uniform_list,
};
@@ -104,7 +104,8 @@ impl PdfView {
if self.thumbing {
return;
}
let Some(page) = rows.clone().find(|page| !self.thumbs.contains_key(&(*page, dark)) && !self.thumbs_failed.contains(page)) else {
let due = |page: &usize| !self.thumbs.contains_key(&(*page, dark)) || self.thumbs_stale.contains(&(*page, dark));
let Some(page) = rows.clone().find(|page| due(page) && !self.thumbs_failed.contains(page)) else {
return;
};
let (w, h) = self.sizes[page];
@@ -127,6 +128,7 @@ impl PdfView {
});
match painted.map(|bgra| crate::pages::picture((tile[2] as u32, tile[3] as u32), bgra)) {
Ok(Some(image)) => {
view.thumbs_stale.remove(&(page, dark));
if let Some(old) = view.thumbs.insert((page, dark), image) {
view.retired.push(old);
}
@@ -145,13 +147,20 @@ impl PdfView {
}
}
impl PdfView {
/// The picture a row draws; an old one until repainted.
pub fn picture(&self, page: usize, dark: bool) -> Option<Arc<RenderImage>> {
self.thumbs.get(&(page, dark)).cloned()
}
}
/// One row: the page number and its picture.
fn row(view: &Entity<PdfView>, page: usize, dark: bool, cx: &App) -> gpui::AnyElement {
let read = view.read(cx);
let colors = &cx.theme().colors;
let (w, h) = read.sizes[page];
let picked = read.picked.contains(&page);
let picture = read.thumbs.get(&(page, dark)).cloned();
let picture = read.picture(page, dark);
let count = read.sizes.len();
let label = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]);
let (clicked, pressed, dropped) = (view.clone(), view.clone(), view.clone());
+59 -1
View File
@@ -1,6 +1,6 @@
//! Editing text and objects from the app (C11.3).
use ely_pdf_engine::sample::{Extras, pdf_with};
use ely_pdf_engine::sample::{Extras, one_page, pdf_with};
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext};
use super::input::over;
@@ -339,3 +339,61 @@ fn a_drag_follows_the_hand_when_the_page_shifts_under_it() {
let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect);
assert!((rect[0] - 122.0).abs() < 0.5, "moved as far as the hand: {rect:?}");
}
#[test]
fn text_on_a_full_page_picture_aims_at_the_text_and_a_shake_stays_a_click() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("scan.pdf");
let extras = Extras { picture: true, backdrop: true, ..Extras::default() };
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &extras)).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
cx.update(|_, app| view.update(app, |view, cx| {
view.zoom = crate::layout::Zoom::Scale(1.0);
view.set_tool(Tool::Edit, cx)
}));
settle(cx, |app| view.read(app).layers.contains_key(&0));
click(cx, &view, (80.0, 66.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the picture under it");
// A two-pixel wobble on the picture moves nothing.
let from = over(cx, &view, 0, (300.0, 400.0));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
let to = from + gpui::point(gpui::px(2.0), gpui::px(0.0));
cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
frames(cx);
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { .. })), "the picture is aimed");
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "and not moved");
}
#[test]
fn a_small_picture_on_text_under_a_page_form_still_aims() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("layered.pdf");
// The page-size form comes last, on top of both.
let content = "q 10 0 0 10 80 722 cm /Im1 Do Q BT /F1 18 Tf 72 720 Td (Hello world) Tj ET q /Fm1 Do Q";
let form = "0 0 1 rg 500 50 20 20 re f";
let objects = [
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{form}\nendstream", form.len()),
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream".to_string(),
];
let page = " /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R /Im1 7 0 R >> >>";
std::fs::write(&path, one_page("", page, content, &objects)).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
cx.update(|_, app| view.update(app, |view, cx| {
view.zoom = crate::layout::Zoom::Scale(1.0);
view.set_tool(Tool::Edit, cx)
}));
settle(cx, |app| view.read(app).layers.contains_key(&0));
// The picture sits at 80..90 across, 60..70 down.
click(cx, &view, (85.0, 65.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { index: 0, .. })), "the small picture");
click(cx, &view, (140.0, 66.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the form around it");
}
+169
View File
@@ -185,3 +185,172 @@ fn tiles_beside_the_view_are_neither_asked_nor_kept() {
}));
settle(cx, |app| columns(app).contains(&last) && !columns(app).contains(&0));
}
/// Flags any notify leaving a shown page with nothing drawn.
fn never_bare(cx: &mut VisualTestContext, view: &Entity<PdfView>) -> (gpui::Subscription, std::rc::Rc<std::cell::Cell<bool>>) {
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
let bare = std::rc::Rc::new(std::cell::Cell::new(false));
let seen = bare.clone();
let watch = cx.update(|_, app| {
app.observe(view, move |view, app| {
let read = view.read(app);
if read.showing(sf, dark).iter().any(|page| read.pieces(page).is_empty()) {
seen.set(true);
}
})
});
(watch, bare)
}
/// Only new tiles drawn, and none on its way.
fn repainted(view: &PdfView) -> bool {
view.backdrop.is_empty() && !view.painting && !view.tiles.is_empty()
}
fn note() -> ely_pdf_engine::NewAnnot {
ely_pdf_engine::NewAnnot {
page: 0,
shape: ely_pdf_engine::Shape::Note((100.0, 500.0)),
rgb: None,
contents: "Look".into(),
author: String::new(),
}
}
#[test]
fn an_edit_keeps_the_page_drawn_until_its_repaint_lands() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
let view = shown(cx, &viewer);
settle(cx, |app| repainted(view.read(app)));
let before: Vec<_> = cx.update(|_, app| view.read(app).tiles.values().cloned().collect());
let (_watch, bare) = never_bare(cx, &view);
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note()], cx)));
settle(cx, |app| view.read(app).edited == 1 && repainted(view.read(app)));
assert!(!bare.get(), "the page never goes blank");
let after = cx.update(|_, app| view.read(app).tiles.values().cloned().collect::<Vec<_>>());
assert!(
after.iter().all(|image| before.iter().all(|old| !std::sync::Arc::ptr_eq(old, image))),
"every tile was painted again"
);
}
#[test]
fn a_new_scale_keeps_the_old_tiles_until_new_ones_land() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
let view = shown(cx, &viewer);
settle(cx, |app| repainted(view.read(app)));
let scale = cx.update(|_, app| view.read(app).laid_out().scale);
let (_watch, bare) = never_bare(cx, &view);
cx.simulate_resize(gpui::size(px(700.), px(800.)));
// The new size lays out before any new tile lands.
cx.run_until_parked();
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
let now = cx.update(|_, app| {
let read = view.read(app);
read.showing(sf, dark).iter().map(|page| read.pieces(page).len()).collect::<Vec<_>>()
});
assert!(now.iter().all(|count| *count > 0), "drawn right after the resize: {now:?}");
settle(cx, |app| view.read(app).laid_out().scale != scale && repainted(view.read(app)));
assert!(!bare.get(), "the page never goes blank");
}
#[test]
fn an_edit_past_the_first_hundred_sizes_keeps_the_page_drawn() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut pages: Vec<(f32, f32, &[&str])> = vec![(612.0, 792.0, &["Page"]); 100];
pages.push((612.0, 900.0, &["Taller"]));
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "mixed.pdf", &pages)], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).sizes[100] == (612.0, 900.0) && repainted(view.read(app)));
let (_watch, bare) = never_bare(cx, &view);
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note()], cx)));
settle(cx, |app| view.read(app).edited == 1 && repainted(view.read(app)));
assert!(!bare.get(), "the page never goes blank");
}
#[test]
fn an_edit_on_page_101_keeps_its_measured_size_and_its_tiles() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut pages: Vec<(f32, f32, &[&str])> = vec![(612.0, 792.0, &["Page"]); 100];
pages.push((612.0, 900.0, &["Taller"]));
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "mixed.pdf", &pages)], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).sizes[100] == (612.0, 900.0));
cx.update(|_, app| view.update(app, |view, cx| view.show(100, 0.0, cx)));
settle(cx, |app| view.read(app).current == 100 && repainted(view.read(app)));
let (_watch, bare) = never_bare(cx, &view);
let late = ely_pdf_engine::NewAnnot { page: 100, ..note() };
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![late], cx)));
settle(cx, |app| view.read(app).edited == 1 && repainted(view.read(app)));
assert!(!bare.get(), "page 101 never goes blank");
assert_eq!(cx.update(|_, app| view.read(app).sizes[100]), (612.0, 900.0), "its measured size holds");
}
#[test]
fn old_tiles_are_painted_and_freed_with_their_tab() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
let view = shown(cx, &viewer);
settle(cx, |app| repainted(view.read(app)));
// Nothing new is asked; only old tiles can paint.
let before = cx.update(|_, app| {
view.update(app, |view, cx| {
let keys: Vec<_> = view.tiles.keys().copied().collect();
view.failed.extend(keys);
view.keep_all();
cx.notify();
view.painted
})
});
cx.run_until_parked();
let (painted, kept) = cx.update(|_, app| (view.read(app).painted, view.read(app).backdrop.len()));
// Old tiles cover what shows of the page.
let shown = cx.update(|window, app| {
let read = view.read(app);
let room = read.room;
read.showing(window.scale_factor(), false).iter().map(|s| {
let (left, top) = (-f32::from(read.scroll.offset().x), -f32::from(read.scroll.offset().y));
let w = (s.place.x + s.place.w).min(left + f32::from(room.size.width)) - s.place.x.max(left);
let h = (s.place.y + s.place.h).min(top + f32::from(room.size.height)) - s.place.y.max(top);
f64::from(w.max(0.0) * h.max(0.0))
}).sum::<f64>()
});
assert!(kept > 0 && painted - before >= shown * 0.99, "{kept} old tiles painted {} of {shown}", painted - before);
cx.update(|_, app| view.update(app, |view, _| view.retire_all()));
assert!(cx.update(|_, app| view.read(app).backdrop.is_empty()), "closing hands every old tile over");
}
#[test]
fn an_edit_keeps_page_pictures_until_they_repaint() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
let view = shown(cx, &viewer);
let dark = cx.update(|_, app| ely_gpui_component::theme::ActiveTheme::theme(app).is_dark());
settle(cx, |app| view.read(app).picture(0, dark).is_some() && repainted(view.read(app)));
let old = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
// A picture on its way holds back the repaint.
cx.update(|_, app| view.update(app, |view, _| view.thumbing = true));
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note()], cx)));
settle(cx, |app| view.read(app).edited == 1 && repainted(view.read(app)));
// Dropped from the atlas, it returns only if drawn.
cx.update(|window, _| window.drop_image(old.clone())).expect("drops");
super::frames(cx);
assert!(cx.update(|window, _| window.has_image_atlas_entry(&old)), "the old picture shows meanwhile");
cx.update(|_, app| view.update(app, |view, cx| {
view.thumbing = false;
cx.notify()
}));
settle(cx, |app| view.read(app).thumbs_stale.is_empty());
let new = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
assert!(!std::sync::Arc::ptr_eq(&new, &old), "the new picture replaces it");
super::frames(cx);
assert!(cx.update(|window, _| window.has_image_atlas_entry(&new)), "and is drawn");
}
+33
View File
@@ -33,6 +33,39 @@ fn pages_export_as_images_and_text() {
assert!(text.contains("First") && text.contains("Second"), "{text}");
}
#[cfg(unix)]
#[test]
fn a_write_the_system_refuses_reports_it_without_a_crash() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["First"])]);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
shown(cx, &viewer);
let locked = dir.path().join("locked");
std::fs::create_dir(&locked).expect("a folder");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).expect("read-only");
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let txt = locked.join("doc.txt");
let how = Optimizing { compress: true, from: 300, to: 0 };
// The window's toasts live on its root.
let root = cx.update(|window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
root
});
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_export(id, Kind::Text, how, txt.clone(), cx)));
let failed = |app: &gpui::App| {
let toasts = root.read(app).toasts(app);
toasts.iter().any(|(text, severity)| {
*severity == Some(ely_gpui_component::primitives::Severity::Danger) && text.contains("Permission denied")
})
};
settle(cx, failed);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).expect("writable again");
assert!(!txt.exists(), "nothing is written");
}
#[test]
fn a_damaged_file_opens_repaired_and_saves_as_a_new_file() {
let dir = tempfile::tempdir().expect("a temp dir");
+17 -7
View File
@@ -37,10 +37,12 @@ pub struct PdfView {
pub tiles: HashMap<TileKey, Arc<RenderImage>>,
/// Images no longer drawn, freed at the next render.
pub retired: ely_gpui_component::primitives::Retired,
/// A tile reached the screen; logged once (PERF-05).
pub drawn: bool,
/// Tile pixels painted; the first paint is logged (PERF-05).
pub painted: f64,
/// Tiles the worker could not draw; not asked again.
pub failed: HashSet<TileKey>,
/// Old tiles shown under their page until covered.
pub backdrop: Vec<crate::backdrop::Old>,
pub text: HashMap<usize, Arc<Vec<Char>>>,
pub asking_text: HashSet<usize>,
pub marks: Vec<Bookmark>,
@@ -69,6 +71,8 @@ pub struct PdfView {
/// Page pictures for the side panel, by page and darkness.
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
pub thumbs_failed: HashSet<usize>,
/// Pictures from before an edit; shown until repainted.
pub thumbs_stale: HashSet<(usize, bool)>,
/// A picture is on its way from the worker.
pub thumbing: bool,
/// Pages picked in the side panel, and the anchor.
@@ -143,7 +147,8 @@ impl PdfView {
pub fn retire_all(&mut self) -> ely_gpui_component::primitives::Retired {
let mut images = std::mem::take(&mut self.retired);
let tiles = self.tiles.drain().map(|(_, image)| image);
for image in tiles.chain(self.thumbs.drain().map(|(_, image)| image)) {
let old = self.backdrop.drain(..).map(|old| old.image);
for image in tiles.chain(old).chain(self.thumbs.drain().map(|(_, image)| image)) {
images.push(image);
}
images
@@ -164,8 +169,9 @@ impl PdfView {
scroll: ScrollHandle::new(),
tiles: HashMap::new(),
retired: Default::default(),
drawn: false,
painted: 0.0,
failed: HashSet::new(),
backdrop: Vec::new(),
text: HashMap::new(),
asking_text: HashSet::new(),
marks: loaded.marks,
@@ -184,6 +190,7 @@ impl PdfView {
mark: loaded.mark,
thumbs: HashMap::new(),
thumbs_failed: HashSet::new(),
thumbs_stale: HashSet::new(),
thumbing: false,
picked: BTreeSet::new(),
anchor: None,
@@ -223,10 +230,10 @@ impl PdfView {
pub fn fail(&self, what: &str, error: &io::Error, cx: &mut Context<Self>) {
// Engine text may quote the document: the user sees it.
log::error!("pdf: {what} failed ({:?})", error.kind());
let text = match error.kind() {
let text = match ely_pdf_engine::refused(error) {
_ if ely_pdf_engine::is_stale(error) => ely_ui::i18n::text(cx, "pdf.object.changed", &[]),
io::ErrorKind::PermissionDenied => restricted(&error.to_string(), cx),
_ => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]),
Some(needed) => restricted(needed, cx),
None => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]),
};
cx.emit(Failed(text.to_string()));
}
@@ -311,6 +318,9 @@ impl PdfView {
/// Every page tile goes; images are freed next render.
pub fn retire_tiles(&mut self) {
for old in self.backdrop.drain(..) {
self.retired.push(old.image);
}
for (_, image) in self.tiles.drain() {
self.retired.push(image);
}
+1 -2
View File
@@ -115,7 +115,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let root = ely_ui::document_root(app, viewer.clone().into(), &commands::FILE, crate::keys::menus(cx), window, cx);
viewer.update(cx, |viewer, cx| {
viewer.core.root = root.downgrade();
viewer.core.window = Some(window.window_handle());
viewer.own_window(window, cx);
window.focus(&viewer.core.focus, cx);
});
for path in ely_ui::launch_files(cx) {
@@ -247,7 +247,6 @@ impl Render for Viewer {
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_export))
.on_action(cx.listener(Self::on_print))
.on_action(cx.listener(Self::on_quit))
.on_action(cx.listener(Self::on_close_tab))
.child(shell)
.children(self.core_dialog(cx))
+13 -57
View File
@@ -1,16 +1,15 @@
//! ElyShow's window: decks over one LOK worker (§10.1).
use std::{
collections::{HashMap, HashSet},
collections::HashSet,
path::PathBuf,
sync::{Arc, Mutex},
};
use ely_gpui_component::{feedback::EmptyState, primitives::IconName, shell::StatusBar};
use ely_lok::{
LokView, Sheets, Worker,
history::Step,
show::{Design, Objects},
LokView, Worker,
show::Design,
};
use ely_office::{Core, Kind, prelude::*};
use ely_platform::AppId;
@@ -18,61 +17,13 @@ use ely_ui::{Root, Shell, commands};
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement as _, Render, RenderImage, SharedString, Styled as _, Window, div,
ParentElement as _, Render, SharedString, Styled as _, Window, div,
};
use crate::design::Panel;
use crate::slides::Strip;
/// Parts, edits, caret and selected frame.
pub type Seen = (u64, u64, bool, Option<ely_lok::Rect>);
/// What ElyShow keeps per deck.
#[derive(Default)]
pub struct Extra {
pub slides: Sheets,
/// The document as the last read saw it.
pub read_at: Option<ely_lok::Doc>,
/// What the view showed at the last read.
pub read: Option<Seen>,
pub reading: bool,
pub design: Option<Design>,
pub objects: Option<Objects>,
pub notes: Option<ely_lok::show::Notes>,
pub remarks: Vec<ely_lok::show::Remark>,
/// The shown slide's transition and effects; none on a master.
pub motion: Option<(ely_lok::show::Transition, Vec<ely_lok::show::Effect>)>,
pub media: Vec<ely_lok::show::Media>,
/// Counts completed reads.
pub reads: u64,
/// The slide size in twips.
pub size: (i64, i64),
/// Undo and redo lists at the last read.
pub history: Option<(Vec<Step>, Vec<Step>)>,
/// A size sent, in 1/100 mm, until read back.
pub size_asked: Option<(i64, i64)>,
/// A transition sent for a slide, until read back.
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
/// Pictures by slide name; stale ones show until repainted.
pub thumbs: HashMap<String, Arc<RenderImage>>,
pub retired: ely_gpui_component::primitives::Retired,
pub stale: HashSet<String>,
/// Pictures that failed; retried after the next read.
pub failed: HashSet<String>,
pub painting: bool,
/// The parts are masters.
pub masters: bool,
/// The scheme the pictures were painted with.
pub dark: Option<bool>,
}
impl Extra {
/// The size to show, in 1/100 mm: asked, else read.
pub fn shown_size(&self) -> (i64, i64) {
self.size_asked
.unwrap_or_else(|| crate::design::mm100(self.size))
}
}
pub use crate::extra::Extra;
pub struct Deck {
pub core: Core<Deck>,
@@ -137,7 +88,13 @@ impl Host for Deck {
}
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
view.update(cx, |view, cx| view.fit_page(cx));
// A new deck fits once; a zoom then stays.
if let Some(tab) = self.tab_mut(id)
&& !tab.extra.fitted
{
tab.extra.fitted = true;
view.update(cx, |view, cx| view.fit_page(cx));
}
// Pictures follow the kernel's light or dark scheme.
let dark = view.read(cx).dark();
if let Some(tab) = self.tab_mut(id)
@@ -176,7 +133,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let root = ely_ui::document_root(app, deck.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
deck.update(cx, |deck, cx| {
deck.core.root = root.downgrade();
deck.core.window = Some(window.window_handle());
deck.own_window(window, cx);
window.focus(&deck.core.focus, cx);
});
let launched = ely_ui::launch_files(cx);
@@ -491,7 +448,6 @@ impl Render for Deck {
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_export))
.on_action(cx.listener(Self::on_print))
.on_action(cx.listener(Self::on_quit))
.on_action(cx.listener(Self::on_close_tab))
.child(shell)
.children(self.core_dialog(cx))
+65
View File
@@ -0,0 +1,65 @@
//! What ElyShow keeps per deck.
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use ely_lok::{
Sheets,
history::Step,
show::{Design, Objects},
};
use gpui::RenderImage;
/// Parts, edits, caret and selected frame.
pub type Seen = (u64, u64, bool, Option<ely_lok::Rect>);
/// What ElyShow keeps per deck.
#[derive(Default)]
pub struct Extra {
pub slides: Sheets,
/// The document as the last read saw it.
pub read_at: Option<ely_lok::Doc>,
/// What the view showed at the last read.
pub read: Option<Seen>,
pub reading: bool,
pub design: Option<Design>,
pub objects: Option<Objects>,
pub notes: Option<ely_lok::show::Notes>,
pub remarks: Vec<ely_lok::show::Remark>,
/// The shown slide's transition and effects; none on a master.
pub motion: Option<(ely_lok::show::Transition, Vec<ely_lok::show::Effect>)>,
pub media: Vec<ely_lok::show::Media>,
/// Counts completed reads.
pub reads: u64,
/// The slide size in twips.
pub size: (i64, i64),
/// Undo and redo lists at the last read.
pub history: Option<(Vec<Step>, Vec<Step>)>,
/// A size sent, in 1/100 mm, until read back.
pub size_asked: Option<(i64, i64)>,
/// A transition sent for a slide, until read back.
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
/// Pictures by slide name; stale ones show until repainted.
pub thumbs: HashMap<String, Arc<RenderImage>>,
pub retired: ely_gpui_component::primitives::Retired,
pub stale: HashSet<String>,
/// Pictures that failed; retried after the next read.
pub failed: HashSet<String>,
pub painting: bool,
/// The parts are masters.
pub masters: bool,
/// The scheme the pictures were painted with.
pub dark: Option<bool>,
/// The view was fitted to the slide once.
pub fitted: bool,
}
impl Extra {
/// The size to show, in 1/100 mm: asked, else read.
pub fn shown_size(&self) -> (i64, i64) {
self.size_asked
.unwrap_or_else(|| crate::design::mm100(self.size))
}
}
+1
View File
@@ -3,6 +3,7 @@ mod check;
mod deck;
mod design;
mod dialog;
mod extra;
mod guides;
mod insert;
mod list;
+2 -2
View File
@@ -292,8 +292,8 @@ impl Deck {
},
move |deck, written, _, cx| match written {
Ok(count) => {
let count = count.to_string();
let text = Deck::text(cx, "show.output.pictures_done", &[("n", &count), ("folder", &shown)]);
let n = count.to_string();
let text = ely_ui::i18n::counted(cx, "show.output.pictures_done", count as u64, &[("n", &n), ("folder", &shown)]);
deck.toast_output(text, opened.clone(), cx);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
+38 -3
View File
@@ -18,8 +18,8 @@ use ely_lok::{Doc, Worker, show::ShowSlide};
use ely_office::prelude::*;
use ely_platform::AppId;
use gpui::{
AnyElement, AnyWindowHandle, App, AppContext as _, Context, Entity, EntityId, IntoElement,
ParentElement as _, PlatformDisplay, Window, WindowBounds, WindowOptions,
AnyElement, AnyWindowHandle, App, AppContext as _, Bounds, Context, Entity, EntityId, IntoElement,
ParentElement as _, Pixels, PlatformDisplay, Window, WindowBounds, WindowOptions,
};
use super::{Show, audience::Audience, presenter::Presenter};
@@ -80,9 +80,20 @@ fn copy(
}
}
/// macOS covers its menu bar and Dock by simple fullscreen.
const SIMPLE: bool = cfg!(target_os = "macos");
/// A show window's bounds: fullscreen, or macOS's simple kind.
fn bounds(area: Bounds<Pixels>) -> WindowBounds {
match SIMPLE {
true => WindowBounds::Windowed(area),
false => WindowBounds::Fullscreen(area),
}
}
fn options(display: &Rc<dyn PlatformDisplay>) -> WindowOptions {
WindowOptions {
window_bounds: Some(WindowBounds::Fullscreen(display.bounds())),
window_bounds: Some(bounds(display.bounds())),
display_id: Some(display.id()),
titlebar: None,
focus: true,
@@ -91,6 +102,13 @@ fn options(display: &Rc<dyn PlatformDisplay>) -> WindowOptions {
}
}
/// Hides the menu bar and Dock over a macOS show.
fn cover(window: &mut Window) {
if SIMPLE {
window.toggle_simple_fullscreen();
}
}
/// Opens one show window; its root scope holds the keys.
pub fn show_window(
show: &Entity<Show>,
@@ -102,6 +120,7 @@ pub fn show_window(
Ok(match presenter {
true => cx
.open_window(options(display), |window, cx| {
cover(window);
let view = cx.new(|cx| Presenter::new(show, cx));
window.focus(&view.read(cx).focus.clone(), cx);
view
@@ -109,6 +128,7 @@ pub fn show_window(
.into(),
false => cx
.open_window(options(display), |window, cx| {
cover(window);
let view = cx.new(|cx| Audience::new(show, cx));
window.focus(&view.read(cx).focus.clone(), cx);
view
@@ -313,3 +333,18 @@ impl Deck {
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use gpui::{Bounds, WindowBounds, point, px, size};
#[test]
fn a_show_covers_the_display_on_every_platform() {
let area = Bounds::new(point(px(0.), px(0.)), size(px(1920.), px(1080.)));
let want = match cfg!(target_os = "macos") {
true => WindowBounds::Windowed(area),
false => WindowBounds::Fullscreen(area),
};
assert_eq!(super::bounds(area), want);
}
}
+24
View File
@@ -384,3 +384,27 @@ fn a_comment_picked_on_another_slide_shows_its_slide_again() {
settle(&cx, |app| current(app) == Some(0));
assert_eq!(cx.update(|app| deck.read(app).shown_remark()), Some(key));
}
#[test]
fn a_zoom_stays_through_later_reports() {
use ely_gpui_component::shell::ZoomStep;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let view = |app: &gpui::App| deck.read(app).active_tab().expect("a tab").view.clone();
settle(&cx, |app| view(app).read(app).fitting());
update(&mut cx, &deck, |deck, cx| deck.zoom(ZoomStep::In, cx));
let zoom = cx.update(|app| view(app).read(app).zoom());
update(&mut cx, &deck, |deck, cx| {
let (id, view) = deck.active_tab().map(|tab| (tab.id, tab.view.clone())).expect("a tab");
deck.observed(id, &view, cx);
});
cx.update(|app| {
let view = view(app);
assert!(!view.read(app).fitting(), "a report does not fit the slide again");
assert_eq!(view.read(app).zoom(), zoom, "the zoom stays");
});
}
+5 -3
View File
@@ -143,7 +143,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let root = ely_ui::document_root(app, book.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
book.update(cx, |book, cx| {
book.core.root = root.downgrade();
book.core.window = Some(window.window_handle());
book.own_window(window, cx);
window.focus(&book.core.focus, cx);
});
let launched = ely_ui::launch_files(cx);
@@ -229,7 +229,10 @@ impl Book {
match read {
Ok((sheets, geometry, at)) => {
log::info!("table: tab {id} has {} sheets", sheets.list.len());
view.update(cx, |view, _| view.set_part(sheets.current));
view.update(cx, |view, cx| {
view.set_part(sheets.current);
view.set_geometry(geometry.clone(), cx);
});
let extra = &mut tab.extra;
extra.sheets = sheets;
extra.geometry = geometry;
@@ -369,7 +372,6 @@ impl Render for Book {
.on_action(cx.listener(Self::on_open_file))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_quit))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(Self::on_export))
.on_action(cx.listener(Self::on_print))
+1 -1
View File
@@ -311,7 +311,7 @@ impl Book {
move |book, picked, _, window, cx| {
let (c1, _, c2, _) = picked.area.expect("a data area");
if c1 != c2 {
return book.warn_key("table.columns.one", cx);
return book.warn_key("table.columns.same", cx);
}
let other = cx.new(|cx| TextInput::new(window, cx));
book.dialog = Some(Dialog::Columns {
+27 -2
View File
@@ -58,8 +58,8 @@ pub fn label(status: &str, cx: &App) -> SharedString {
if let Some((_, key)) = ERRORS.iter().find(|(known, _)| *known == status) {
return Book::text(cx, key, &[]);
}
let parts: Vec<String> = status
.split("; ")
let parts: Vec<String> = fields(status)
.into_iter()
.map(|part| {
let known = part.split_once(": ").and_then(|(name, value)| {
let (_, key) = FIGURES.iter().find(|(known, _)| *known == name)?;
@@ -74,6 +74,24 @@ pub fn label(status: &str, cx: &App) -> SharedString {
parts.join(" ").into()
}
/// The status split at each "Name: "; values keep semicolons.
fn fields(status: &str) -> Vec<&str> {
let starts = |rest: &str| {
rest.split_once(": ")
.is_some_and(|(name, _)| !name.is_empty() && name.chars().all(|c| c.is_ascii_alphabetic() || c == ' '))
};
let mut out = Vec::new();
let mut from = 0;
for (at, _) in status.match_indices("; ") {
if starts(&status[at + 2..]) {
out.push(&status[from..at]);
from = at + 2;
}
}
out.push(&status[from..]);
out
}
#[cfg(test)]
mod tests {
#[test]
@@ -89,6 +107,13 @@ mod tests {
super::Book::text(app, "table.figure.sum", &[("value", "42")])
);
assert_eq!(label.as_ref(), expect, "known names relabel, others stay");
// The kernel's formatted values pass through untouched.
ely_ui::i18n::install_as("en-US", app);
let custom = super::label("Average: 8128.00 kg; net; Sum: 16256.00 kg; net", app);
assert_eq!(custom.as_ref(), "Average 8128.00 kg; net Sum 16256.00 kg; net", "a format's own semicolons stay");
for value in ["8128.00", "8128.33333333333", "0.0000000001", "1234567890.25", "$1,234.50"] {
assert_eq!(super::label(&format!("Sum: {value}"), app).as_ref(), format!("Sum {value}"));
}
for (english, key) in super::ERRORS {
assert_eq!(super::label(english, app), super::Book::text(app, key, &[]));
}
+2 -2
View File
@@ -99,7 +99,7 @@ impl Book {
};
let length = if column { width } else { height };
let pick = |pair: (i32, i32)| if column { pair.0 } else { pair.1 };
let (frozen, scroll) = (pick(read.frozen()), pick(read.scroll()));
let (frozen, first) = (pick(read.frozen()), pick(read.scrolled_start()));
let mut spans = Vec::new();
let mut take = |index: u32| {
let (start, end) = (along(axis.start(index)), along(axis.start(index + 1)));
@@ -114,7 +114,7 @@ impl Book {
0
};
(0..held).for_each(&mut take);
let mut index = axis.at(frozen + scroll).max(held);
let mut index = axis.at(first).max(held);
while index < axis.count() && along(axis.start(index)) < length {
take(index);
index += 1;
+6 -10
View File
@@ -114,19 +114,15 @@ pub(crate) fn delete(
let mut detail = if references.formulas + references.names == 0 {
Book::text(cx, "table.delete.alone", &[])
} else {
Book::text(
cx,
"table.delete.refs",
&[
("formulas", &references.formulas.to_string()),
("first", &references.first),
("names", &references.names.to_string()),
],
)
let count = |key, n: u64| ely_ui::i18n::counted(cx, key, n, &[("n", &n.to_string())]);
let formulas = count("table.delete.formulas", references.formulas);
let names = count("table.delete.names", references.names);
let args = [("formulas", formulas.as_ref()), ("first", &references.first), ("names", &names)];
Book::text(cx, "table.delete.refs", &args)
};
if references.dynamic > 0 {
let count = references.dynamic.to_string();
let dynamic = Book::text(cx, "table.delete.dynamic", &[("count", &count)]);
let dynamic = ely_ui::i18n::counted(cx, "table.delete.dynamic", references.dynamic, &[("count", &count)]);
detail = format!("{detail} {dynamic}").into();
}
let label = Book::text(cx, "table.delete.go", &[]);
+2 -1
View File
@@ -95,9 +95,10 @@ impl Tree {
Ask::Force { repo, plan } => {
let lost = match plan.lost.len() {
0 => label("tree.force.nothing"),
n => text(
n => ely_ui::i18n::counted(
cx,
"tree.force.lost",
n as u64,
&[("n", &n.to_string()), ("commits", &plan.lost.join("\n"))],
),
};
+2 -2
View File
@@ -101,8 +101,8 @@ impl Tree {
};
let lines = (!shown.picked.is_empty() && self.partial(shown)).then(|| {
let this = this.clone();
let n = shown.picked.len().to_string();
Button::new("tree-lines", text(cx, key, &[("n", &n)]))
let n = shown.picked.len();
Button::new("tree-lines", ely_ui::i18n::counted(cx, key, n as u64, &[("n", &n.to_string())]))
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
+2 -1
View File
@@ -205,9 +205,10 @@ impl Tree {
let short = |oid: &str| oid[..oid.len().min(7)].to_string();
let left = match plan.left.len() {
0 => label("tree.move.nothing"),
n => text(
n => ely_ui::i18n::counted(
cx,
"tree.move.left",
n as u64,
&[("n", &n.to_string()), ("commits", &plan.left.join("\n"))],
),
};
+23 -7
View File
@@ -209,13 +209,7 @@ impl Tree {
.iter()
.filter(|entry| entry.state != State::Ignored)
.count();
let (icon, title) = match changed {
0 => (IconName::CircleCheck, text(cx, "tree.clean", &[])),
n => (
IconName::GitBranch,
text(cx, "tree.changed", &[("n", &n.to_string())]),
),
};
let (icon, title) = summary(changed, cx);
EmptyState::new(("tree-summary", opened.id), icon, title).into_any_element()
}
}
@@ -263,3 +257,25 @@ impl Render for Tree {
.children(self.dialog(cx))
}
}
/// The work tree's line: clean, or how many files changed.
pub(crate) fn summary(changed: usize, cx: &App) -> (IconName, SharedString) {
match changed {
0 => (IconName::CircleCheck, text(cx, "tree.clean", &[])),
n => (IconName::GitBranch, ely_ui::i18n::counted(cx, "tree.changed", n as u64, &[("n", &n.to_string())])),
}
}
#[cfg(test)]
mod tests {
#[gpui::test]
fn the_summary_counts_files_in_both_languages(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
ely_ui::i18n::install_as("en-US", cx);
let said = |n: usize, cx: &gpui::App| super::summary(n, cx).1.to_string();
assert_eq!([said(0, cx), said(1, cx), said(2, cx)], ["Nothing to commit", "1 file changed", "2 files changed"]);
ely_ui::i18n::install_as("zh-CN", cx);
assert_eq!([said(1, cx), said(2, cx)], ["1 个文件有改动", "2 个文件有改动"]);
});
}
}
+16 -37
View File
@@ -16,6 +16,7 @@ mod doc;
mod feed;
mod frames;
mod install;
mod load;
pub use install::{install_dir, templates};
use install::worker_bin;
pub use doc::{Pdf, Sheet, Sheets};
@@ -37,6 +38,8 @@ pub struct Worker {
session: u64,
stderr: ely_ipc::Relay,
check: Check,
/// The name new comments sign with, once set.
author: Option<String>,
}
/// The start-up check: to run, passed, or why it failed.
@@ -73,9 +76,16 @@ pub struct Doc {
id: u64,
/// The document's revision when this handle was taken.
pub(crate) rev: u64,
/// LOK_DOCTYPE: 0 text, 1 spreadsheet, 2 presentation.
kind: u64,
}
impl Doc {
/// Whether this is a spreadsheet.
pub fn is_sheet(&self) -> bool {
self.kind == 1
}
/// Whether this handle saw a later state than `other`.
pub fn newer_than(&self, other: &Doc) -> bool {
self.rev > other.rev
@@ -181,6 +191,7 @@ impl Worker {
session: SESSIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
stderr,
check: Check::Pending(profile_dir.to_path_buf()),
author: None,
};
Ok(worker)
}
@@ -194,47 +205,15 @@ impl Worker {
/// The name new comments and tracked changes carry.
pub fn set_author(&mut self, name: &str) -> io::Result<()> {
log::info!("lok: the author is set");
// Wanted even if this worker fails; a restart sends it.
self.author = Some(name.to_string());
self.request(tag::SET_AUTHOR, &Pack::new().str(name).bytes())?;
Ok(())
}
/// Loads `url`; macros never run (LOK-11).
pub fn load(&mut self, url: &str) -> io::Result<Doc> {
self.unlock(url, "")
}
/// Loads text `url` as a sheet, read as `how` says.
pub fn import(&mut self, url: &str, how: &crate::sheet::csv::Import) -> io::Result<Doc> {
use crate::sheet::csv::{COLUMNS, ENCODINGS};
let at = |found: Option<usize>| found.expect("a listed choice") as u64;
let pack = Pack::new()
.str(url)
.u64(u64::from(how.separator))
.u64(how.quote.map_or(0, u64::from))
.u64(at(ENCODINGS.iter().position(|e| *e == how.encoding)))
.u64(how.columns.len() as u64);
let pack = how.columns.iter().fold(pack, |pack, column| {
pack.u64(at(COLUMNS.iter().position(|c| c == column)))
});
let payload = self.request(tag::IMPORT, &pack.bytes())?;
let id = Unpack::new(&payload).u64()?;
self.feed.opened(id);
Ok(Doc { worker: self.session, id, rev: 0 })
}
/// Loads `url` with its password; a locked one is PermissionDenied.
pub fn unlock(&mut self, url: &str, password: &str) -> io::Result<Doc> {
let request = Pack::new().str(url).str(password).bytes();
let payload = self.request(tag::LOAD, &request).map_err(|error| {
if error.to_string() == crate::ipc::LOCKED {
io::Error::new(io::ErrorKind::PermissionDenied, error.to_string())
} else {
error
}
})?;
let id = Unpack::new(&payload).u64()?;
self.feed.opened(id);
Ok(Doc { worker: self.session, id, rev: 0 })
/// The name last asked for; a restart signs with it.
pub fn author(&self) -> Option<&str> {
self.author.as_deref()
}
/// Runs a macro by its URL (LOK-11 probe).
+36 -3
View File
@@ -55,14 +55,16 @@ impl Doc {
Ok(Sheets { current, list })
}
/// Tells the kernel the area the view shows, in twips.
pub fn visible_area(&self, worker: &mut Worker, (x, y, w, h): Rect) -> io::Result<()> {
/// Tells the kernel the shown area and the tile scale.
pub fn visible_area(&self, worker: &mut Worker, (x, y, w, h): Rect, (tile, tile_twips): (u32, i32)) -> io::Result<()> {
let request = Pack::new()
.u64(worker.doc_id(self)?)
.i64(x.into())
.i64(y.into())
.i64(w.into())
.i64(h.into())
.i64(tile.into())
.i64(tile_twips.into())
.bytes();
worker.request(tag::VISIBLE_AREA, &request)?;
Ok(())
@@ -178,7 +180,38 @@ impl Doc {
.u64(u64::from(pixels.0))
.u64(u64::from(pixels.1))
.bytes();
crate::show::layers(&worker.request(tag::SLIDE_LAYERS, &request)?)
let head = worker.request(tag::SLIDE_LAYERS, &request)?;
let mut unpack = Unpack::new(&head);
let (width, height, count) = (unpack.u64()?, unpack.u64()?, unpack.u64()?);
// Layers arrive in pieces that keep each frame small.
let mut whole = Pack::new().u64(width).u64(height).u64(count);
for index in 0..count {
let (json, painted) = (unpack.str()?, unpack.u64()?);
whole = whole.str(&json).u64(painted);
if painted != 0 {
whole = whole.blob(&self.slide_layer(worker, index)?);
}
}
crate::show::layers(&whole.bytes())
}
/// Layer `index` of the last layers read, piece by piece.
fn slide_layer(&self, worker: &mut Worker, index: u64) -> io::Result<Vec<u8>> {
let id = worker.doc_id(self)?;
let mut pixels = Vec::new();
loop {
let ask = Pack::new().u64(id).u64(index).u64(pixels.len() as u64).bytes();
let reply = worker.request(tag::SLIDE_LAYER, &ask)?;
let mut unpack = Unpack::new(&reply);
let (total, piece) = (unpack.u64()?, unpack.blob()?);
pixels.extend_from_slice(&piece);
if pixels.len() as u64 >= total {
return Ok(pixels);
}
if piece.is_empty() {
return Err(io::Error::other(format!("layer {index} stopped at byte {}", pixels.len())));
}
}
}
/// Saves to `url` as `format`; failure surfaces (LOK-09).
+3 -3
View File
@@ -181,17 +181,17 @@ mod tests {
};
feed.push(1, first);
feed.push(1, second);
let batch = feed.wait(&Doc { worker: 0, id: 1, rev: 0 }, 0, 0).expect("a batch");
let batch = feed.wait(&Doc { worker: 0, id: 1, rev: 0, kind: 0 }, 0, 0).expect("a batch");
assert_eq!((batch.seq, batch.all, batch.rects.len()), (2, true, 1));
assert_eq!(
batch.states,
[("b".to_string(), "1".to_string()), ("a".into(), "2".into())]
);
let idle = feed.wait(&Doc { worker: 0, id: 1, rev: 0 }, 2, 0).expect("the snapshot");
let idle = feed.wait(&Doc { worker: 0, id: 1, rev: 0, kind: 0 }, 2, 0).expect("the snapshot");
assert!(idle.rects.is_empty() && idle.states.is_empty() && idle.seq == 2);
feed.closed(1);
assert!(
feed.wait(&Doc { worker: 0, id: 1, rev: 0 }, 2, 0).is_err(),
feed.wait(&Doc { worker: 0, id: 1, rev: 0, kind: 0 }, 2, 0).is_err(),
"a closed document"
);
}
+51
View File
@@ -0,0 +1,51 @@
//! Loading documents: by URL, with a password, or as text.
use std::io;
use super::{Doc, Worker};
use crate::ipc::{Pack, Unpack, tag};
impl Worker {
/// Loads `url`; macros never run (LOK-11).
pub fn load(&mut self, url: &str) -> io::Result<Doc> {
self.unlock(url, "")
}
/// Loads text `url` as a sheet, read as `how` says.
pub fn import(&mut self, url: &str, how: &crate::sheet::csv::Import) -> io::Result<Doc> {
use crate::sheet::csv::{COLUMNS, ENCODINGS};
let at = |found: Option<usize>| found.expect("a listed choice") as u64;
let pack = Pack::new()
.str(url)
.u64(u64::from(how.separator))
.u64(how.quote.map_or(0, u64::from))
.u64(at(ENCODINGS.iter().position(|e| *e == how.encoding)))
.u64(how.columns.len() as u64);
let pack = how.columns.iter().fold(pack, |pack, column| {
pack.u64(at(COLUMNS.iter().position(|c| c == column)))
});
let payload = self.request(tag::IMPORT, &pack.bytes())?;
self.opened(&payload)
}
/// Loads `url` with its password; a locked one is PermissionDenied.
pub fn unlock(&mut self, url: &str, password: &str) -> io::Result<Doc> {
let request = Pack::new().str(url).str(password).bytes();
let payload = self.request(tag::LOAD, &request).map_err(|error| {
if error.to_string() == crate::ipc::LOCKED {
io::Error::new(io::ErrorKind::PermissionDenied, error.to_string())
} else {
error
}
})?;
self.opened(&payload)
}
/// A load's reply: the document's number and kind.
fn opened(&mut self, payload: &[u8]) -> io::Result<Doc> {
let mut reply = Unpack::new(payload);
let (id, kind) = (reply.u64()?, reply.u64()?);
self.feed.opened(id);
Ok(Doc { worker: self.session, id, rev: 0, kind })
}
}
+14 -21
View File
@@ -6,6 +6,7 @@ mod analysis;
mod charts;
mod check;
mod commands;
mod filter;
mod format;
mod formula;
mod handouts;
@@ -282,30 +283,22 @@ fn a_production_worker_refuses_run_macro() {
#[cfg(target_os = "macos")]
#[test]
fn an_unpatched_build_is_rejected() {
let dmg = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../engines/LibreOffice_26.2.6_MacOS_aarch64.dmg");
if !dmg.try_exists().expect("the dmg check") {
eprintln!("SKIP: no official dmg");
// A plain folder: a mounted .app would be registered.
let frameworks = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../engines/lo-official/Contents/Frameworks");
if !frameworks.try_exists().expect("the folder check") {
eprintln!("SKIP: run scripts/extract-official-lo.sh");
return;
}
let mount = tempfile::tempdir().expect("a temp dir");
let attached = std::process::Command::new("hdiutil")
.args(["attach", "-nobrowse", "-readonly", "-mountpoint"])
.arg(mount.path())
.arg(&dmg)
.status()
.expect("hdiutil runs");
assert!(attached.success(), "the official dmg mounts");
let frameworks = mount.path().join("LibreOffice.app/Contents/Frameworks");
let dir = tempfile::tempdir().expect("a temp dir");
let outcome = Worker::spawn(&frameworks, &dir.path().join("profile"));
assert!(outcome.is_err(), "the unpatched build must be rejected");
let detached = std::process::Command::new("hdiutil")
.args(["detach", "-quiet"])
.arg(mount.path())
.status()
.expect("hdiutil detaches");
assert!(detached.success());
let Err(error) = Worker::spawn(&frameworks, &dir.path().join("profile")) else {
panic!("the unpatched build must be rejected");
};
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData, "{error}");
assert!(
error.to_string().contains("does not paint tiles (unpatched)"),
"rejected for the tile check, not {error}"
);
}
#[test]
@@ -3,6 +3,8 @@ use super::sheet::open_book;
use super::*;
use crate::sheet::{self, Layout, Look};
const SETTLE: std::time::Duration = std::time::Duration::from_secs(3);
/// Region, Year, Sales; four rows.
fn data() -> String {
let rows: String = [
@@ -331,3 +333,99 @@ fn undoing_a_new_kind_brings_the_whole_chart_back() {
"the stacking came back"
);
}
/// A sheet of ones filling each `(columns, rows)` block.
fn blocks(filled: &[(std::ops::RangeInclusive<usize>, std::ops::RangeInclusive<usize>)]) -> String {
sheet(&rows_of(filled))
}
/// Rows of ones filling each `(columns, rows)` block.
fn rows_of(filled: &[(std::ops::RangeInclusive<usize>, std::ops::RangeInclusive<usize>)]) -> String {
let height = filled.iter().map(|(_, rows)| *rows.end()).max().unwrap_or(0);
(1..=height)
.map(|r| {
let cells: String = (0..20)
.map(|c| match filled.iter().any(|(cols, rows)| cols.contains(&c) && rows.contains(&r)) {
true => r#"<table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell>"#,
false => "<table:table-cell/>",
})
.collect();
format!("<table:table-row>{cells}</table:table-row>")
})
.collect()
}
/// Charts `source` and checks it clears each data block.
fn charted(sheet: &str, visible: (i32, i32, i32, i32), (source, stand): (&str, &str), data: &[(&str, &str)]) {
let (_dir, mut worker, doc) = open_book(sheet);
doc.visible_area(&mut worker, visible, (256, 3840)).expect("the area");
let mut seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
let mut cell = |worker: &mut Worker, name: &str| {
worker.run(&doc, "cell.goto", &[name]).expect("the cell");
let before = seen.cell.cursor;
seen = poll_until(worker, &doc, &seen, SETTLE, |now| now.cell.cursor.is_some() && now.cell.cursor != before);
seen.cell.cursor.expect("the cell's rect")
};
let spans: Vec<_> = data.iter().map(|(from, to)| (cell(&mut worker, from), cell(&mut worker, to))).collect();
let all = written(&mut worker, &doc, source);
worker.run(&doc, "cell.goto", &[stand]).expect("where the chart goes");
worker.run(&doc, "chart.insert", &[&all, "false", &Look { kind: "column".into(), ..Look::default() }.to_json()]).expect("inserts");
let framed = poll_until(&mut worker, &doc, &seen, SETTLE, |now| now.graphic.is_some());
let (x, y, w, h) = framed.graphic.expect("the chart is selected");
for ((a, b), (from, to)) in spans.iter().zip(data) {
let clear = x >= b.0 + b.2 || x + w <= a.0 || y >= b.1 + b.3 || y + h <= a.1;
assert!(clear, "the chart {:?} covers {from}:{to}", (x, y, w, h));
}
}
/// A sheet body as flat XML.
fn sheet(rows: &str) -> String {
sheets(&[("S", rows)])
}
/// Named sheets as one flat XML book.
fn sheets(tables: &[(&str, &str)]) -> String {
let tables: String = tables.iter().map(|(name, rows)| format!(r#"<table:table table:name="{name}">{rows}</table:table>"#)).collect();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet>{tables}</office:spreadsheet></office:body>
</office:document>"#
)
}
const ONE: &str = r#"<table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell>"#;
#[test]
fn a_new_chart_lands_beside_its_data() {
if skip() {
return;
}
// Its own data, a neighbour past two empty columns.
charted(&blocks(&[(0..=2, 1..=5), (5..=15, 1..=20)]), (0, 0, 15000, 10000), ("A1:C5", "A1"), &[("A1", "C5"), ("F1", "P20")]);
// Data wider and taller than the view.
charted(&blocks(&[(0..=15, 1..=50)]), (0, 0, 15000, 10000), ("A1:P50", "A1"), &[("A1", "P50")]);
// A view narrower than the chart.
charted(&blocks(&[(0..=2, 1..=5)]), (0, 0, 6000, 3500), ("A1:C5", "A1"), &[("A1", "C5")]);
// A merged block beside the data is taken, not free.
let merged = format!(
r#"<table:table-row>{ONE}{ONE}{ONE}<table:table-cell table:number-columns-spanned="13" table:number-rows-spanned="20"><text:p>MERGED</text:p></table:table-cell></table:table-row><table:table-row table:number-rows-repeated="4">{ONE}{ONE}{ONE}</table:table-row>"#
);
charted(&sheet(&merged), (0, 0, 15000, 10000), ("A1:C5", "A1"), &[("A1", "C5"), ("D1", "D1")]);
// Data in the last cells: left or above.
let corner = |above: usize| {
let empty = 1048574 - above;
format!(
r#"<table:table-row table:number-rows-repeated="{empty}"><table:table-cell table:number-columns-repeated="16384"/></table:table-row><table:table-row table:number-rows-repeated="2"><table:table-cell table:number-columns-repeated="16382"/>{ONE}{ONE}</table:table-row>"#
)
};
charted(&sheet(&corner(0)), (0, 0, 15000, 10000), ("XFC1048575:XFD1048576", "A1"), &[("XFC1048575", "XFD1048576")]);
// Corner data, a block at A1: room elsewhere.
let full = format!("{}{}", rows_of(&[(0..=15, 1..=20)]), corner(20));
charted(&sheet(&full), (0, 0, 15000, 10000), ("XFC1048575:XFD1048576", "A1"), &[("A1", "P20"), ("XFC1048575", "XFD1048576")]);
// Data on S, chart on T: T's cells stay clear.
let two = sheets(&[("S", &rows_of(&[(0..=2, 1..=5)])), ("T", &rows_of(&[(0..=15, 1..=20)]))]);
charted(&two, (0, 0, 15000, 10000), ("S!A1:C5", "T!A1"), &[("T!A1", "T!P20")]);
}
+154
View File
@@ -0,0 +1,154 @@
//! An AutoFilter range keeps its rows (TABLE-08).
use super::sheet::open_book;
use super::*;
use crate::sheet;
/// A header, three numbers and their total right under them.
const BLOCK: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-row><table:table-cell><text:p>Name</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="6"><text:p>6</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
/// Two blocks: A1:A5 as in `BLOCK`, and D1:D3.
const TWO: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-row><table:table-cell><text:p>Name</text:p></table:table-cell><table:table-cell table:number-columns-repeated="2"/><table:table-cell><text:p>Other</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell><table:table-cell table:number-columns-repeated="2"/><table:table-cell office:value-type="float" office:value="9"><text:p>9</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell><table:table-cell table:number-columns-repeated="2"/><table:table-cell office:value-type="float" office:value="10"><text:p>10</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="6"><text:p>6</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
/// The book saved as flat XML.
fn saved(worker: &mut Worker, doc: &Doc, dir: &std::path::Path) -> String {
let out = dir.join("kept.fods");
doc.save_as(worker, &crate::file_url(&out), "fods").expect("saves");
std::fs::read_to_string(&out).expect("the saved book")
}
/// Whether the row holding `value` is hidden by a filter.
fn hidden(xml: &str, value: &str) -> bool {
let before = &xml[..xml.find(&format!("office:value=\"{value}\"")).expect("the value")];
before[before.rfind("<table:table-row").expect("its row")..].contains("table:visibility=\"filter\"")
}
/// Column A's filter entries.
fn entries(worker: &mut Worker, doc: &Doc) -> Vec<String> {
let query = sheet::cell_query(sheet::FILTER, 0, 0);
let json = worker.values(doc, &query).expect("the entries");
sheet::filter_values(&json).expect("parses").entries.into_iter().map(|e| e.text).collect()
}
/// A book with an AutoFilter set on `marked`.
fn filtered(marked: &str) -> (tempfile::TempDir, Worker, Doc) {
let (dir, mut worker, doc) = open_book(BLOCK);
worker.run(&doc, "cell.goto", &[marked]).expect("marks the cells");
worker.run(&doc, "data.autofilter", &[]).expect("filters");
(dir, worker, doc)
}
/// Keeps 1: the saved range, and whether the total hides.
fn keep_one(worker: &mut Worker, doc: &Doc, dir: &std::path::Path) -> (String, bool) {
worker.run(doc, "cell.goto", &["A1"]).expect("A1");
let one = r#"{"column":0,"conditions":[{"op":"values","values":["1"]}]}"#;
worker.run(doc, "data.filter", &[one]).expect("keeps 1");
let xml = saved(worker, doc, dir);
let range = xml.split("table:target-range-address=\"").nth(1).expect("a filter range");
(range[..range.find('"').expect("closed")].to_string(), hidden(&xml, "6"))
}
#[test]
fn an_autofilter_on_a_marked_block_leaves_the_row_below_out() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = filtered("A1:A4");
assert_eq!(entries(&mut worker, &doc), ["1", "2", "3"], "the total below stays out");
assert_eq!(keep_one(&mut worker, &doc, dir.path()), ("S.A1:S.A4".into(), false), "applied from one cell");
}
#[test]
fn an_autofilter_on_one_cell_takes_the_data_area() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = filtered("A1");
assert_eq!(entries(&mut worker, &doc), ["1", "2", "3", "6"], "the whole column of data");
}
#[test]
fn off_and_undone_or_set_on_a_filtered_block_the_rows_stay() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = filtered("A1:A4");
worker.run(&doc, "cell.goto", &["A1"]).expect("A1");
worker.run(&doc, "data.autofilter", &[]).expect("off");
worker.run(&doc, "edit.undo", &[]).expect("back on");
assert_eq!(entries(&mut worker, &doc), ["1", "2", "3"], "undo keeps the rows");
let (_dir, mut worker, doc) = open_book(BLOCK);
worker.run(&doc, "cell.goto", &["A1:A4"]).expect("marks the block");
let one = r#"{"conditions":[{"field":0,"op":"equals","value":"1"}]}"#;
worker.run(&doc, "data.filter", &[one]).expect("a standard filter");
worker.run(&doc, "cell.goto", &["A1:A4"]).expect("marks it again");
worker.run(&doc, "data.autofilter", &[]).expect("filters");
assert_eq!(entries(&mut worker, &doc), ["1", "2", "3"], "a filtered block keeps its rows");
}
#[test]
fn a_saved_autofilter_range_keeps_its_rows_when_reopened() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
for format in ["xlsx", "ods"] {
let (dir, mut worker, doc) = filtered("A1:A4");
let path = dir.path().join(format!("book.{format}"));
doc.save_as(&mut worker, &crate::file_url(&path), format).expect("saves");
worker.close_doc(doc).expect("closes");
let doc = worker.load(&crate::file_url(&path)).expect("reopens");
assert_eq!(entries(&mut worker, &doc), ["1", "2", "3"], "{format}");
assert_eq!(keep_one(&mut worker, &doc, dir.path()), ("S.A1:S.A4".into(), false), "{format}");
}
}
#[test]
fn one_cell_in_another_block_filters_that_block() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_book(TWO);
worker.run(&doc, "cell.goto", &["A1:A4"]).expect("marks the block");
worker.run(&doc, "data.autofilter", &[]).expect("filters A");
worker.run(&doc, "cell.goto", &["D1"]).expect("D1");
let nine = r#"{"conditions":[{"field":3,"op":"equals","value":"9"}]}"#;
worker.run(&doc, "data.filter", &[nine]).expect("keeps 9");
let xml = saved(&mut worker, &doc, dir.path());
assert!(hidden(&xml, "10") && !hidden(&xml, "9"), "D keeps 9 alone");
assert!(!hidden(&xml, "3") && !hidden(&xml, "6"), "A's rows past D stay");
let (_dir, mut worker, doc) = open_book(TWO);
worker.run(&doc, "cell.goto", &["A1:A4"]).expect("marks the block");
worker.run(&doc, "data.autofilter", &[]).expect("filters A");
worker.run(&doc, "cell.goto", &["D1"]).expect("D1");
worker.run(&doc, "data.autofilter", &[]).expect("filters D");
let json = worker.values(&doc, &sheet::cell_query(sheet::FILTER, 3, 0)).expect("D's entries");
let entries: Vec<String> = sheet::filter_values(&json).expect("parses").entries.into_iter().map(|e| e.text).collect();
assert_eq!(entries, ["9", "10"], "an AutoFilter from D1 is D's");
}
@@ -360,3 +360,20 @@ fn names_are_defined_changed_removed_and_flag_breaks() {
.expect("a removal");
assert!(!read(&mut worker).iter().any(|name| name.name == "Local"));
}
#[test]
fn selection_figures_follow_the_cells_number_format() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc, seen) = open();
enter(&mut worker, &doc, &[("H1", "8128.33333333333"), ("H2", "8128.33333333333")]);
worker.run(&doc, "cell.goto", &["H1:H2"]).expect("both");
worker.run(&doc, "number.code", &["0.00"]).expect("two decimals");
let figures = |seen: &Events| {
seen.states.iter().rfind(|(name, _)| name == ".uno:StateTableCell").map(|(_, value)| value.clone())
};
let state = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| figures(seen).is_some_and(|v| v.contains("8128.33;")));
assert!(figures(&state).is_some_and(|v| v.contains("Average: 8128.33;")), "{:?}", figures(&state));
}
+59
View File
@@ -220,3 +220,62 @@ fn an_animated_object_is_its_own_layer_under_its_effect_hash() {
#[test]
fn layers_larger_than_one_frame_arrive_one_by_one() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
worker.run(&doc, "draw.shape", &["rectangle", "true"]).expect("draws");
let json = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string();
worker.run(&doc, "animation.add", &[&json]).expect("adds");
// Three layers at this size pass one 64 MB frame.
let layers = doc.slide_layers(&mut worker, 0, (5000, 2812)).expect("layers");
let (w, h) = layers.size;
let bytes = w as usize * h as usize * 4;
assert!(layers.list.len() * bytes > crate::ipc::MAX_FRAME, "{} layers of {bytes}", layers.list.len());
assert!(layers.list.iter().all(|layer| layer.pixels.len() == bytes), "every layer arrives whole");
}
#[test]
fn a_layer_larger_than_one_frame_arrives_in_pieces() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
worker.run(&doc, "slide.size", &["10000", "120000", "true"]).expect("a tall slide");
worker.run(&doc, "draw.shape", &["rectangle", "true"]).expect("draws");
let layers = doc.slide_layers(&mut worker, 0, (1280, 15360)).expect("layers");
let (w, h) = layers.size;
let bytes = w as usize * h as usize * 4;
assert!(bytes > crate::ipc::MAX_FRAME, "one layer of {bytes} bytes");
assert!(layers.list.iter().all(|layer| layer.pixels.len() == bytes), "every layer arrives whole");
// The rectangle lands where the kernel put it.
let objects = show::objects(&worker.values(&doc, show::OBJECTS).expect("reads")).expect("parses");
let shape = objects.objects.iter().find(|o| o.selected).expect("the new rectangle");
let want = ((shape.rect.1 + shape.rect.3 / 2) as f64 / 120000.0 * f64::from(h)) as usize;
let row = |layer: &show::Layer, y: usize| layer.pixels[y * w as usize * 4..(y + 1) * w as usize * 4].chunks(4).any(|px| px[3] != 0);
let inked = |layer: &show::Layer| (0..h as usize).filter(|y| row(layer, *y)).collect::<Vec<_>>();
assert!(want > 16 * 1024 * 1024 / (w as usize * 4), "the shape lies past the first piece");
let around = want - 20..want + 20;
let found = layers.list.iter().map(inked).any(|rows| around.clone().all(|y| rows.contains(&y)) && rows.len() < h as usize);
assert!(found, "no layer inks rows {around:?}");
}
#[test]
fn closing_a_deck_drops_its_waiting_layers() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
let id = worker.doc_id(&doc).expect("the id");
let ask = crate::ipc::Pack::new().u64(id).u64(0).u64(1920).u64(1080).bytes();
worker.request(crate::ipc::tag::SLIDE_LAYERS, &ask).expect("layers wait");
worker.close_doc(doc).expect("closes");
let late = crate::ipc::Pack::new().u64(id).u64(0).u64(0).bytes();
assert!(worker.request(crate::ipc::tag::SLIDE_LAYER, &late).is_err(), "no layer outlives its deck");
}
+1 -1
View File
@@ -176,7 +176,7 @@ pub(super) fn open_book(text: &str) -> (tempfile::TempDir, Worker, Doc) {
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the book loads");
doc.visible_area(&mut worker, (0, 0, 15000, 10000))
doc.visible_area(&mut worker, (0, 0, 15000, 10000), (256, 3840))
.expect("the area");
(dir, worker, doc)
}
+7
View File
@@ -247,6 +247,13 @@ impl Document {
call!(class, 69, F, self.doc, view, true);
}
/// Tells the kernel a tile's size in pixels and twips.
pub fn set_client_zoom(&self, pixels: i32, twips: i32) {
let class = unsafe { (*self.doc).class };
type F = unsafe extern "C" fn(*mut Doc, i32, i32, i32, i32);
call!(class, 23, F, self.doc, pixels, pixels, twips, twips);
}
/// Tells the kernel the area the client shows, in twips.
pub fn set_visible_area(&self, x: i32, y: i32, width: i32, height: i32) {
let class = unsafe { (*self.doc).class };
+2
View File
@@ -86,6 +86,8 @@ pub mod tag {
/// Refuses unless the document is still at a revision.
pub const EXPECT: u8 = 33;
pub const REVISION: u8 = 34;
/// One layer's pixels from the last `SLIDE_LAYERS`.
pub const SLIDE_LAYER: u8 = 35;
pub const OK: u8 = 100;
pub const ERR: u8 = 101;
}
+85
View File
@@ -71,6 +71,66 @@ impl Axis {
}
}
impl Axis {
/// Where `index` starts in tile pixels.
fn tile_start(&self, index: u32, ppt: f64) -> i64 {
let (mut at, mut first) = (0i64, 0u32);
for &(last, size) in &self.runs {
let pixels = i64::from(to_pixels(size, ppt));
if index <= last {
return at + i64::from(index - first) * pixels;
}
at += i64::from(last - first + 1) * pixels;
first = last + 1;
}
at
}
/// A print-twips position in tile twips at `ppt`.
pub fn tile(&self, print: i32, ppt: f64) -> i32 {
let end = self.start(self.count());
if print >= end {
return clamp(twips_of(self.tile_start(self.count(), ppt), ppt) + i64::from(print - end));
}
let index = self.at(print);
let (from, size) = (self.start(index), self.size(index));
let start = twips_of(self.tile_start(index, ppt), ppt);
let span = twips_of(self.tile_start(index + 1, ppt), ppt) - start;
clamp(start + i64::from(print - from) * span / i64::from(size))
}
/// The print-twips position under tile twips `tile`.
pub fn print(&self, tile: i32, ppt: f64) -> i32 {
let pixel = (f64::from(tile.max(0)) * ppt) as i64;
let (mut at, mut first) = (0i64, 0u32);
for &(last, size) in &self.runs {
let pixels = i64::from(to_pixels(size, ppt));
let span = i64::from(last - first + 1) * pixels;
if pixels > 0 && pixel < at + span {
let index = first + u32::try_from((pixel - at) / pixels).expect("inside a run");
let start = twips_of(self.tile_start(index, ppt), ppt);
let width = twips_of(self.tile_start(index + 1, ppt), ppt) - start;
let into = i64::from(tile) - start;
return clamp(i64::from(self.start(index)) + into * i64::from(size) / width.max(1));
}
at += span;
first = last + 1;
}
let end = twips_of(self.tile_start(self.count(), ppt), ppt);
clamp(i64::from(self.start(self.count())) + i64::from(tile) - end)
}
}
/// The kernel's `ScViewData::ToPixel`: truncates, never to 0.
fn to_pixels(twips: i32, ppt: f64) -> i32 {
let pixels = (f64::from(twips) * ppt) as i32;
if pixels == 0 && twips != 0 { 1 } else { pixels }
}
fn twips_of(pixels: i64, ppt: f64) -> i64 {
(pixels as f64 / ppt).round() as i64
}
fn clamp(twips: i64) -> i32 {
i32::try_from(twips).unwrap_or(i32::MAX)
}
@@ -371,4 +431,29 @@ mod tests {
assert_eq!(flags("1:2 5").expect("flags"), [(2, 1), (5, 0)]);
assert!(merge(&[(3, 10)], &[(5, 0)]).is_err(), "lengths must agree");
}
#[test]
fn tiles_round_each_row_to_whole_pixels() {
// 293 twips at 2/15 px per twip truncate to 39.
let rows = Axis { runs: vec![(9999, 293)] };
let ppt = 2.0 / 15.0;
assert_eq!(to_pixels(293, ppt), 39);
let row = 4997u32;
let print = rows.start(row);
let tile = rows.tile(print, ppt);
assert_eq!(tile, (f64::from(39 * row) / ppt).round() as i32, "row tops line up");
assert_eq!(rows.print(tile, ppt), print, "and come back");
let mid = rows.tile(print + 146, ppt);
assert_eq!(rows.at(rows.print(mid, ppt)), row, "a point inside stays in its row");
assert!(tile < print, "rounding down pulls far rows up");
}
#[test]
fn hidden_rows_take_no_tile_space_and_tiny_rows_take_a_pixel() {
let rows = Axis { runs: vec![(1, 300), (3, 0), (4, 3)] };
let ppt = 1.0 / 15.0;
assert_eq!(rows.tile(rows.start(4), ppt), rows.tile(rows.start(2), ppt), "hidden rows fold");
assert_eq!(to_pixels(3, ppt), 1, "never 0 for a shown row");
}
}
+9 -2
View File
@@ -28,6 +28,7 @@ mod render;
mod zoom;
pub use zoom::{ZOOM_STEPS, Zoomed};
mod scroll;
mod sheet;
pub use input::{CONTEXT, Copy, Cut, Paste, init};
pub use off::read_off;
pub use render::scheme;
@@ -89,13 +90,15 @@ pub struct LokView {
fetching: HashSet<(i32, i32)>,
failed: HashSet<((i32, i32), u64)>,
/// The area last told to the kernel.
shown: Option<Rect>,
shown: Option<(Rect, (u32, i32))>,
viewport: (f32, f32),
origin: Point<Pixels>,
/// Pixels a small document sits in: left, top.
spare: (f32, f32),
/// Frozen columns and rows, in twips from the top left.
frozen: (i32, i32),
/// A spreadsheet's rows and columns, in print twips.
sheet: Option<crate::sheet::Geometry>,
/// The farthest point a jump needed; Calc grows to it.
reach: (i32, i32),
pages: Vec<Rect>,
@@ -210,6 +213,7 @@ impl LokView {
origin: Point::default(),
spare: (0.0, 0.0),
frozen: (0, 0),
sheet: None,
reach: (0, 0),
pages,
focus: cx.focus_handle().tab_stop(true),
@@ -371,8 +375,9 @@ impl LokView {
}
if batch.cursor != self.cursor {
self.moves += 1;
// A moved cursor stays in view.
// A moved cursor stays in view, but not mid-drag.
if self.viewport.1 > 0.0
&& !self.dragging
&& let Some(cursor) = batch.cursor
{
self.show(cursor, cx);
@@ -381,10 +386,12 @@ impl LokView {
// A moved cell cursor stays in view too.
if batch.cell.cursor != self.cell.cursor
&& self.viewport.1 > 0.0
&& !self.dragging
&& let Some(cell) = batch.cell.cursor
{
self.show(cell, cx);
}
self.follow_graphic(batch.graphic, cx);
self.cursor = batch.cursor;
self.cursor_visible = batch.cursor_visible;
self.selection = batch.selection;
+7 -6
View File
@@ -60,8 +60,8 @@ pub(super) enum Input {
from: (i32, i32),
to: (i32, i32),
},
/// The area the view shows, in twips.
Visible(Rect),
/// The area shown in twips, and the tile scale.
Visible(Rect, (u32, i32)),
/// Shows sheet or slide `part`, from 0.
Part(usize),
}
@@ -74,7 +74,7 @@ impl Input {
Input::Key { key_code, .. } => !matches!(key_code & 0x0FFF, 1024..=1031 | 1281),
Input::Mouse(_)
| Input::Copy { cut: false }
| Input::Visible(_)
| Input::Visible(..)
| Input::Part(_)
| Input::Theme(_) => false,
Input::Run { commands, .. } => commands.iter().any(|(id, _)| *id != "edit.select_all"),
@@ -171,7 +171,7 @@ fn lok_button(button: MouseButton) -> Option<i32> {
/// Runs one input; gives a copy's mark and the revision.
pub(super) fn run(worker: &Mutex<Worker>, doc: &Doc, input: Input) -> (io::Result<Option<String>>, Option<u64>) {
let mut worker = worker.lock().expect("the worker lock");
let counts = !matches!(input, Input::Visible(_));
let counts = !matches!(input, Input::Visible(..));
let copied = apply(&mut worker, doc, input);
// A failed input may have moved the revision too.
let now = match counts {
@@ -189,7 +189,7 @@ fn apply(worker: &mut Worker, doc: &Doc, input: Input) -> io::Result<Option<Stri
} => worker.post_key(doc, char_code, key_code),
Input::Mouse(event) => worker.post_mouse(doc, event),
Input::Drag { from, to } => worker.drag_graphic(doc, from, to),
Input::Visible(area) => doc.visible_area(worker, area),
Input::Visible(area, scale) => doc.visible_area(worker, area, scale),
Input::Part(part) => doc.show_sheet(worker, part),
Input::Complete(name) => doc.complete_function(worker, &name),
Input::Theme(scheme) => worker.run(doc, "view.theme", &[scheme]),
@@ -280,7 +280,8 @@ impl LokView {
count: usize,
modifiers: &Modifiers,
) {
let (x, y) = self.doc_point(position);
// The kernel reads clicks in tile twips.
let (x, y) = self.tile_point(position);
self.send(Input::Mouse(Mouse {
kind,
x,
+9 -3
View File
@@ -23,7 +23,8 @@ pub(super) struct Job {
/// What came back from the lane.
enum Landed {
Input(u64, bool, (io::Result<Option<String>>, Option<u64>)),
/// Sequence, whether it edits, whether it switched part, outcome.
Input(u64, bool, bool, (io::Result<Option<String>>, Option<u64>)),
Tile(Job, io::Result<Tile>),
}
@@ -46,6 +47,7 @@ impl LokView {
input = input_queue.next() => {
let Some((seq, input, sent)) = input else { break };
let (waited, edits) = (sent.elapsed().as_millis(), input.edits());
let switched = matches!(input, super::input::Input::Part(_));
let outcome = super::input::run(&worker, &doc, input);
let took = sent.elapsed().as_millis();
if took > SLOW_MS {
@@ -53,7 +55,7 @@ impl LokView {
} else {
log::debug!("lok: input {seq} waited {waited}, took {took} ms");
}
Landed::Input(seq, edits, outcome)
Landed::Input(seq, edits, switched, outcome)
}
job = tile_queue.next() => {
let Some(job) = job else { break };
@@ -96,8 +98,12 @@ impl LokView {
let mut reported = false;
for landed in batch {
match landed {
Landed::Input(seq, edits, outcome) => {
Landed::Input(seq, edits, switched, outcome) => {
self.applied = seq;
// Same tile keys, another slide or sheet.
if switched {
self.invalidate(cx);
}
self.edited += u64::from(edits);
let (outcome, now) = outcome;
if edits {
+21 -17
View File
@@ -40,7 +40,7 @@ impl Render for LokView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let dpi = window.scale_factor();
if dpi != self.geom.dpi {
self.geom.dpi = dpi;
self.keep_scroll(|view| view.geom.dpi = dpi);
self.retire_tiles();
}
self.follow_theme(cx.theme().is_dark());
@@ -210,7 +210,7 @@ impl LokView {
let down = self.viewport.1 * TWIPS_PER_PIXEL / h as f32;
let zoom = (across.min(down) * MARGIN).min(4.0);
if (zoom - self.geom.zoom).abs() > 0.001 {
self.geom.zoom = zoom;
self.keep_scroll(|view| view.geom.zoom = zoom);
self.retire_tiles();
self.failed.clear();
}
@@ -249,6 +249,19 @@ impl LokView {
}
}
/// Room beside a narrow document, which sits centred.
pub(super) fn slack(&self) -> (f32, f32) {
// A sheet's grid grows with the view; it starts left.
if self.doc.is_sheet() {
return (0.0, 0.0);
}
let extent = self.extent();
let slack = |view: f32, doc: i32| (view - self.geom.px_of(doc)).max(0.0) / 2.0;
// A fitted page sits centred both ways.
let down = if self.fit { slack(self.viewport.1, extent.1) } else { 0.0 };
(slack(self.viewport.0, extent.0), down)
}
/// The page mask and visible tiles; requests the missing ones.
pub(super) fn prepare(&mut self, bounds: Bounds<Pixels>) -> Painted {
self.viewport = (bounds.size.width.into(), bounds.size.height.into());
@@ -259,20 +272,10 @@ impl LokView {
self.clamp_scroll();
let geom = self.geom;
let (sx, sy) = self.scroll;
// A narrow document sits centered.
let extent = self.extent();
let slack = |view: f32, doc: i32| (view - geom.px_of(doc)).max(0.0) / 2.0;
// A fitted page sits centred both ways.
self.spare = (
slack(self.viewport.0, extent.0),
if self.fit {
slack(self.viewport.1, extent.1)
} else {
0.0
},
);
self.spare = self.slack();
let pages = if self.pages.is_empty() {
vec![self.screen_rect((0, 0, extent.0, extent.1))]
vec![self.tile_screen_rect((0, 0, extent.0, extent.1))]
} else {
self.pages
.iter()
@@ -283,10 +286,11 @@ impl LokView {
geom.twips_of(bounds.size.width.into()),
geom.twips_of(bounds.size.height.into()),
);
if self.shown != Some((sx, sy, vw, vh)) {
self.shown = Some((sx, sy, vw, vh));
let scale = (geom.tile_pixels(), TILE_TWIPS);
if self.shown != Some(((sx, sy, vw, vh), scale)) {
self.shown = Some(((sx, sy, vw, vh), scale));
self.failed.clear();
self.send(input::Input::Visible((sx, sy, vw, vh)));
self.send(input::Input::Visible((sx, sy, vw, vh), scale));
}
let (ax, ay) = self.axes();
let side = px(geom.px_of(TILE_TWIPS));
+56 -25
View File
@@ -41,29 +41,37 @@ impl LokView {
self.scroll_by(dx, dy, cx);
}
/// Scrolls `rect` into view; frozen parts always show.
pub fn show(&mut self, (x, y, w, h): Rect, cx: &mut Context<Self>) {
let (fx, fy) = self.frozen;
/// A newly selected object, such as a chart, shows.
pub(super) fn follow_graphic(&mut self, frame: Option<Rect>, cx: &mut Context<Self>) {
if let (None, Some(frame), true) = (self.graphic, frame, self.viewport.1 > 0.0) {
self.show(frame, cx);
}
}
/// Scrolls print-twips `rect` into view; frozen parts always show.
pub fn show(&mut self, rect: Rect, cx: &mut Context<Self>) {
let (x, y, w, h) = self.tile_rect(rect);
let (fx, fy) = self.frozen_tiles();
let (vw, vh) = (
self.geom.twips_of(self.viewport.0) - fx,
self.geom.twips_of(self.viewport.1) - fy,
);
// Room past the target, so the clamp lets it show.
self.reach = (
self.reach.0.max(x + w + vw + fx),
self.reach.1.max(y + h + vh + fy),
);
if x >= fx && (x < fx + self.scroll.0 || x + w > fx + self.scroll.0 + vw) {
self.scroll.0 = x - fx - vw / 2;
// Only a sheet grows where it jumps.
if self.sheet.is_some() {
let need = self.print_of((x + w + vw + fx, y + h + vh + fy));
self.reach = (self.reach.0.max(need.0), self.reach.1.max(need.1));
}
if y >= fy && (y < fy + self.scroll.1 || y + h > fy + self.scroll.1 + vh) {
self.scroll.1 = y - fy - vh / 3;
if x >= fx {
self.scroll.0 = least(x - fx, w, self.scroll.0, vw);
}
if y >= fy {
self.scroll.1 = least(y - fy, h, self.scroll.1, vh);
}
self.clamp_scroll();
cx.notify();
}
/// Freezes what lies before `frozen`, in twips.
/// Freezes what lies before `frozen`, in print twips.
pub fn set_frozen(&mut self, frozen: (i32, i32), cx: &mut Context<Self>) {
if self.frozen != frozen {
log::info!("lok: frozen at {frozen:?}");
@@ -75,7 +83,8 @@ impl LokView {
/// Scrolls `rect` into view and puts the cursor there.
pub fn reveal(&mut self, rect: Rect, cx: &mut Context<Self>) {
self.show(rect, cx);
let (x, y, _, h) = rect;
// Clicks land in tile twips.
let (x, y, _, h) = self.tile_rect(rect);
log::info!("lok: reveal at {x},{y}");
for kind in [0, 1] {
let (count, buttons, modifiers) = (1, 1, 0);
@@ -102,14 +111,19 @@ impl LokView {
if self.fit {
return self.doc_size;
}
(
self.tile_of((
self.doc_size.0.max(self.reach.0),
self.doc_size.1.max(self.reach.1),
)
))
}
/// The document point under a view position (LOK-04).
/// The print-twips document point under a view position (LOK-04).
pub fn doc_point(&self, position: Point<Pixels>) -> (i32, i32) {
self.print_of(self.tile_point(position))
}
/// The tile-twips point under a view position.
pub(super) fn tile_point(&self, position: Point<Pixels>) -> (i32, i32) {
let local = position - self.origin;
let (ax, ay) = self.axes();
(
@@ -121,14 +135,17 @@ impl LokView {
/// The panes of each axis.
pub(super) fn axes(&self) -> (super::panes::Axis, super::panes::Axis) {
let axis = |frozen, scroll| super::panes::Axis { frozen, scroll };
(
axis(self.frozen.0, self.scroll.0),
axis(self.frozen.1, self.scroll.1),
)
let (fx, fy) = self.frozen_tiles();
(axis(fx, self.scroll.0), axis(fy, self.scroll.1))
}
/// The window point of a document point in twips (LOK-04).
pub fn window_point(&self, (x, y): (i32, i32)) -> gpui::Point<Pixels> {
/// The window point of a print-twips document point (LOK-04).
pub fn window_point(&self, at: (i32, i32)) -> gpui::Point<Pixels> {
self.tile_window_point(self.tile_of(at))
}
/// The window point of a tile-twips point.
pub(super) fn tile_window_point(&self, (x, y): (i32, i32)) -> gpui::Point<Pixels> {
let (ax, ay) = self.axes();
let (x, y) = (self.geom.px_of(ax.show(x)), self.geom.px_of(ay.show(y)));
self.origin + point(px(self.spare.0 + x), px(self.spare.1 + y))
@@ -139,12 +156,26 @@ impl LokView {
Bounds::from_corners(self.window_point((x, y)), self.window_point((x + w, y + h)))
}
/// A tile-twips rect in window pixels.
pub(super) fn tile_screen_rect(&self, (x, y, w, h): crate::Rect) -> Bounds<Pixels> {
Bounds::from_corners(self.tile_window_point((x, y)), self.tile_window_point((x + w, y + h)))
}
/// The cursor rect in window pixels.
pub fn cursor_bounds(&self) -> Option<Bounds<Pixels>> {
let (x, y, w, h) = self.cursor?;
let origin = self.window_point((x, y));
let end = self.window_point((x + w, y + h));
Some(Bounds {
origin: self.window_point((x, y)),
size: size(px(self.geom.px_of(w.max(1))), px(self.geom.px_of(h))),
origin,
size: size((end.x - origin.x).max(px(self.geom.px_of(1))), end.y - origin.y),
})
}
}
/// The scroll nearest `scroll` that shows `start..start + len`.
fn least(start: i32, len: i32, scroll: i32, view: i32) -> i32 {
// A span wider than the view keeps covering it.
let end = start + len - view;
scroll.clamp(start.min(end), start.max(end))
}
+64
View File
@@ -0,0 +1,64 @@
//! Calc reports print twips; tiles and clicks take tile twips.
use gpui::Context;
use super::{LokView, TILE_TWIPS};
use crate::{Rect, sheet::Geometry};
impl LokView {
/// Lays the sheet out the kernel's way.
pub fn set_geometry(&mut self, geometry: Geometry, cx: &mut Context<Self>) {
if self.sheet.as_ref() != Some(&geometry) {
self.sheet = Some(geometry);
self.clamp_scroll();
cx.notify();
}
}
/// Device pixels per twip, as the kernel paints our tiles.
fn ppt(&self) -> f64 {
f64::from(self.geom.tile_pixels()) / f64::from(TILE_TWIPS)
}
/// A print-twips point in tile twips.
pub(super) fn tile_of(&self, (x, y): (i32, i32)) -> (i32, i32) {
match &self.sheet {
Some(sheet) => (sheet.columns.tile(x, self.ppt()), sheet.rows.tile(y, self.ppt())),
None => (x, y),
}
}
/// A tile-twips point in print twips.
pub(super) fn print_of(&self, (x, y): (i32, i32)) -> (i32, i32) {
match &self.sheet {
Some(sheet) => (sheet.columns.print(x, self.ppt()), sheet.rows.print(y, self.ppt())),
None => (x, y),
}
}
/// A print-twips rect in tile twips.
pub(super) fn tile_rect(&self, (x, y, w, h): Rect) -> Rect {
let (left, top) = self.tile_of((x, y));
let (right, bottom) = self.tile_of((x + w, y + h));
(left, top, right - left, bottom - top)
}
/// The frozen extent in tile twips.
pub(super) fn frozen_tiles(&self) -> (i32, i32) {
self.tile_of(self.frozen)
}
/// Rescales, keeping the scrolling pane's start in place.
pub(super) fn keep_scroll(&mut self, change: impl FnOnce(&mut Self)) {
let start = self.scrolled_start();
change(self);
let ((x, y), (fx, fy)) = (self.tile_of(start), self.frozen_tiles());
self.scroll = (x - fx, y - fy);
}
/// Where the scrolling pane starts, in print twips.
pub fn scrolled_start(&self) -> (i32, i32) {
let (fx, fy) = self.frozen_tiles();
self.print_of((fx + self.scroll.0, fy + self.scroll.1))
}
}
-39
View File
@@ -359,45 +359,6 @@ fn a_moved_cursor_scrolls_into_view_and_a_still_one_does_not() {
view.scroll_by(0.0, 1_000_000.0, cx);
view.apply_batch(batch(), None, cx);
assert_eq!(view.scroll.1, 0, "a still cursor leaves the scroll alone");
let far = view.doc_size.1 + 20_000;
let cell = crate::Cell {
cursor: Some((1418, far, 1280, 277)),
..crate::Cell::default()
};
view.apply_batch(
crate::Events {
cell,
..crate::Events::default()
},
None,
cx,
);
let height = view.geom.twips_of(view.viewport.1);
assert!(
view.scroll.1 <= far && far + 277 <= view.scroll.1 + height,
"a cell past the kernel's size still comes into view"
);
view.scroll = (0, 0);
let width = view.geom.twips_of(view.viewport.0);
view.show((width - 200, 0, 1280, 256), cx);
assert!(
view.scroll.0 > 0 && width - 200 + 1280 <= view.scroll.0 + width,
"a cell cut at the right edge comes into view"
);
view.scroll = (0, 0);
view.frozen = (0, 600);
view.show((1418, 300, 1280, 277), cx);
assert_eq!(view.scroll.1, 0, "a frozen row needs no scroll");
view.show((1418, 9000, 1280, 277), cx);
let scrolled = view.scroll.1;
assert!(scrolled > 0, "a far row scrolls the rest");
let row = view.place((0, 300)).y;
assert_eq!(f32::from(row), view.geom.px_of(300), "the frozen row stays");
let far = view.twips_at(view.place((0, 9000)));
assert!(
(far.1 - 9000).abs() <= 15,
"a scrolled row maps back: {far:?}"
);
});
});
}
+74
View File
@@ -88,3 +88,77 @@ fn a_resized_slide_reaches_the_view_and_still_fits() {
})
});
}
#[test]
fn a_page_stays_centred_when_the_cursor_moves() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
let wide = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(2400.0), px(900.0)) };
view.prepare(wide);
let centred = view.spare.0;
assert!(centred > 0.0, "a page narrower than the view sits centred");
view.show((1440, 1440, 20, 300), cx);
view.prepare(wide);
assert_eq!(view.spare.0, centred, "moving the cursor keeps it centred");
let narrow = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(900.0)) };
view.prepare(narrow);
view.set_zoom(2.0, cx);
view.prepare(narrow);
let right = view.scroll.0 + view.geom.twips_of(view.viewport.0);
assert!(right <= view.doc_size.0, "no grey past the page: {right} > {}", view.doc_size.0);
})
});
}
#[test]
fn zooming_keeps_the_left_page_edge_or_the_pointed_spot() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
let room = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(1200.0), px(900.0)) };
view.prepare(room);
let page = *view.pages.first().expect("a laid out page");
// Unclamped, so a cut edge reads below zero.
let left = |view: &LokView| view.spare.0 + view.geom.px_of(view.tile_of((page.0, page.1)).0 - view.scroll.0);
for _ in 0..3 {
view.zoom_step(true, cx);
view.prepare(room);
assert!(left(view) >= 0.0, "at {}% the left edge is at {}", view.zoom() * 100.0, left(view));
}
view.set_zoom(1.0, cx);
view.prepare(room);
view.set_zoom(2.0, cx);
view.prepare(room);
assert!(left(view) >= 0.0, "a set zoom keeps the left edge: {}", left(view));
view.set_zoom(1.0, cx);
view.prepare(room);
let at = point(px(600.0), px(400.0));
let before = view.doc_point(at);
view.zoom_at(1.5, Some(at), cx);
view.prepare(room);
let after = view.doc_point(at);
assert!((after.0 - before.0).abs() < 30 && (after.1 - before.1).abs() < 30, "{before:?} moved to {after:?}");
// Wheel steps that land within one frame.
view.set_zoom(1.0, cx);
view.prepare(room);
let before = view.doc_point(at);
for zoom in [1.25, 1.5, 2.0] {
view.zoom_at(zoom, Some(at), cx);
}
view.prepare(room);
let after = view.doc_point(at);
assert!((after.0 - before.0).abs() < 30 && (after.1 - before.1).abs() < 30, "bunched: {before:?} moved to {after:?}");
})
});
}
+52
View File
@@ -269,3 +269,55 @@ fn the_clipboard_moves_html_tables_and_images() {
let xml = std::fs::read_to_string(&out).expect("the save reads");
assert!(xml.contains("<draw:image"), "the image lands");
}
/// Two slides, each with one line of text.
const DECK: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:body><office:presentation>
<draw:page draw:name="one"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>One</text:p></draw:text-box></draw:frame></draw:page>
<draw:page draw:name="two"><draw:frame svg:x="2cm" svg:y="8cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>Two</text:p></draw:text-box></draw:frame></draw:page>
</office:presentation></office:body>
</office:document>"#;
/// Paints until tile (0, 0) is fresh, then its bytes.
fn fresh_corner(view: &Entity<LokView>, cx: &mut TestAppContext, not: Option<&[u8]>) -> Vec<u8> {
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) };
for _ in 0..400 {
let bytes = cx.update(|app| {
view.update(app, |view, _| view.prepare(bounds));
let read = view.read(app);
let entry = read.tiles.get(&(0, 0)).filter(|entry| !entry.stale)?;
let bytes = entry.image.as_bytes(0).expect("a frame").to_vec();
(Some(bytes.as_slice()) != not).then_some(bytes)
});
if let Some(bytes) = bytes {
return bytes;
}
cx.executor().tick();
}
panic!("tile (0, 0) never repainted");
}
#[test]
fn switching_slides_repaints_the_shown_tiles() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, worker) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK));
let one = fresh_corner(&view, &mut cx, None);
cx.update(|app| view.update(app, |view, _| view.show_part(1)));
let two = fresh_corner(&view, &mut cx, Some(&one));
let painted = cx.update(|app| {
let read = view.read(app);
let side = read.geom.tile_pixels();
let mut worker = worker.lock().expect("the worker lock");
read.doc.paint_tile(&mut worker, (0, 0, TILE_TWIPS, TILE_TWIPS), (side, side)).expect("paints")
});
assert_eq!(two, painted.pixels, "the corner shows slide two");
}
+194
View File
@@ -60,3 +60,197 @@ fn a_tall_sheet_paints_and_keeps_only_tiles_near_the_screen() {
});
});
}
/// 6000 rows of 0.5 cm; tiles round each to pixels.
const SHORT_ROWS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:automatic-styles><style:style style:name="ro1" style:family="table-row"><style:table-row-properties style:row-height="0.5cm" style:use-optimal-row-height="false"/></style:style></office:automatic-styles>
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-row table:style-name="ro1" table:number-rows-repeated="6000"><table:table-cell table:number-columns-repeated="4"/></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
/// A short-row sheet at 2× DPI, scrolled to `cell`'s row.
fn far_down(cx: &mut gpui::TestAppContext, install: &std::path::Path) -> (Entity<LokView>, crate::sheet::Geometry) {
let (view, worker) = cx.update(|app| live_view_of(app, install, "short.fods", SHORT_ROWS));
let json = cx.update(|app| {
let read = view.read(app);
worker.lock().expect("the worker lock").values(&read.doc, crate::sheet::GEOMETRY).expect("geometry")
});
let geometry = crate::sheet::geometry(&json).expect("parsed");
let row = cell(&geometry, 1, 4997);
cx.update(|app| {
view.update(app, |view, cx| {
view.geom.dpi = 2.0;
view.set_geometry(geometry.clone(), cx);
view.prepare(Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) });
view.show(row, cx);
})
});
(view, geometry)
}
/// Column `x`, row `y` in print twips.
fn cell(geometry: &crate::sheet::Geometry, x: u32, y: u32) -> crate::Rect {
(geometry.columns.start(x), geometry.rows.start(y), geometry.columns.size(x), geometry.rows.size(y))
}
/// Waits until the kernel reports `want` as the selection.
fn selected(view: &Entity<LokView>, cx: &mut gpui::TestAppContext, want: &str) -> String {
let mut address = String::new();
for _ in 0..200 {
cx.executor().tick();
address = cx.update(|app| view.read(app).cell().address.clone());
if address == want {
break;
}
}
address
}
#[test]
fn a_click_far_down_a_sheet_selects_the_cell_under_it() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = gpui::TestAppContext::single();
let (view, geometry) = far_down(&mut cx, &install);
let (x, y, w, h) = cell(&geometry, 1, 4997);
cx.update(|app| {
view.update(app, |view, _| {
let at = view.window_point((x + w / 2, y + h / 2));
for kind in [0, 1] {
view.mouse(kind, at, 1, 1, &gpui::Modifiers::default());
}
})
});
assert_eq!(selected(&view, &mut cx, "B4998"), "B4998");
}
#[test]
fn a_drag_far_down_a_sheet_selects_the_range_under_it() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = gpui::TestAppContext::single();
let (view, geometry) = far_down(&mut cx, &install);
let middle = |(x, y, w, h): crate::Rect| (x + w / 2, y + h / 2);
let (from, to) = (middle(cell(&geometry, 1, 4997)), middle(cell(&geometry, 3, 5000)));
cx.update(|app| {
view.update(app, |view, _| {
let modifiers = gpui::Modifiers::default();
view.mouse(0, view.window_point(from), 1, 1, &modifiers);
view.mouse(2, view.window_point(((from.0 + to.0) / 2, (from.1 + to.1) / 2)), 1, 1, &modifiers);
view.mouse(2, view.window_point(to), 1, 1, &modifiers);
view.mouse(1, view.window_point(to), 1, 1, &modifiers);
})
});
assert_eq!(selected(&view, &mut cx, "B4998:D5001"), "B4998:D5001");
}
#[test]
fn zooming_far_down_a_sheet_keeps_the_rows_in_view() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = gpui::TestAppContext::single();
let (view, geometry) = far_down(&mut cx, &install);
let middle = |cx: &mut gpui::TestAppContext| {
cx.update(|app| {
let read = view.read(app);
let (w, h) = read.viewport();
let at = read.origin() + point(px(w / 2.0), px(h / 2.0));
geometry.rows.at(read.doc_point(at).1)
})
};
let before = middle(&mut cx);
cx.update(|app| view.update(app, |view, cx| view.set_zoom(0.8, cx)));
let after = middle(&mut cx);
assert!(before.abs_diff(after) <= 1, "row {before} became {after}");
}
#[test]
fn a_sheet_brings_far_cut_and_frozen_cells_into_view() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, worker) = live_view_of(app, &install, "short.fods", SHORT_ROWS);
let json = worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry");
view.update(app, |view, cx| {
let wide = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(6000.0), px(200.0)) };
view.prepare(wide);
assert_eq!(view.spare, (0.0, 0.0), "before its geometry a sheet starts left");
view.set_geometry(crate::sheet::geometry(&json).expect("parsed"), cx);
view.prepare(wide);
assert_eq!(view.spare, (0.0, 0.0), "and stays left after it");
view.viewport = (800.0, 200.0);
let (width, height) = (view.geom.twips_of(view.viewport.0), view.geom.twips_of(view.viewport.1));
let shows = |view: &LokView, rect: crate::Rect| {
let (x, y, w, h) = view.tile_rect(rect);
let (fx, fy) = view.frozen_tiles();
x + w <= fx + view.scroll.0 + width && y >= fy + view.scroll.1 && y + h <= fy + view.scroll.1 + height
};
let far = (1418, view.doc_size.1 + 20_000, 1280, 277);
let cell = crate::Cell { cursor: Some(far), ..crate::Cell::default() };
view.apply_batch(crate::Events { cell, ..crate::Events::default() }, None, cx);
assert!(shows(view, far), "a cell past the kernel's size still comes into view");
view.scroll = (0, 0);
let cut = (width - 200, 0, 1280, 256);
view.show(cut, cx);
assert!(view.scroll.0 > 0 && shows(view, cut), "a cell cut at the right edge comes into view");
let (x, _, w, _) = view.tile_rect(cut);
assert_eq!(view.scroll.0, x + w - width, "it scrolls no further than the cell's edge");
let before = view.scroll;
view.show((0, 0, view.print_of((x + w + width, 0)).0, 256), cx);
assert_eq!(view.scroll, before, "a cell wider than the view and covering it keeps still");
view.dragging = true;
let cell = crate::Cell { cursor: Some((1418, 90_000, 1280, 277)), ..crate::Cell::default() };
view.apply_batch(crate::Events { cell, ..crate::Events::default() }, None, cx);
assert_eq!(view.scroll, before, "a drag does not scroll under the pointer");
view.dragging = false;
view.scroll = (0, 0);
view.set_frozen((0, 600), cx);
let frozen = view.place((0, 300)).y;
view.show((1418, 300, 1280, 277), cx);
assert_eq!(view.scroll.1, 0, "a frozen row needs no scroll");
view.show((1418, 9000, 1280, 277), cx);
assert!(view.scroll.1 > 0, "a far row scrolls the rest");
assert_eq!(view.place((0, 300)).y, frozen, "the frozen row stays");
let back = view.twips_at(view.place((0, 9000)));
assert!((back.1 - 9000).abs() <= 15, "a scrolled row maps back: {back:?}");
});
});
}
#[test]
fn a_newly_selected_object_comes_into_view_a_moved_one_stays() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view_of(app, &install, "tall.fods", TALL);
view.update(app, |view, cx| {
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) };
view.prepare(bounds);
let far = (60_000, 40_000, 9_000, 5_000);
let chart = |graphic| crate::Events { graphic, ..crate::Events::default() };
view.apply_batch(chart(Some(far)), None, cx);
view.prepare(bounds);
let (x, y) = (view.scroll.0, view.scroll.1);
assert!(x > 0 && y > 0, "the new chart is shown: scroll {x},{y}");
view.apply_batch(chart(Some((61_000, 40_000, 9_000, 5_000))), None, cx);
assert_eq!((view.scroll.0, view.scroll.1), (x, y), "dragging it does not scroll");
});
});
}
+11 -2
View File
@@ -47,14 +47,23 @@ impl LokView {
Some(at) => (f32::from(at.x - self.origin.x), f32::from(at.y - self.origin.y)),
None => (self.viewport.0 / 2.0, self.viewport.1 / 2.0),
};
let under = (self.scroll.0 + self.geom.twips_of(ax), self.scroll.1 + self.geom.twips_of(ay));
// The offset now, not the last frame's: zooms may bunch.
let spare = self.slack();
let (sx, sy) = (ax - spare.0, ay - spare.1);
// Anchored in print twips: tile twips change with the zoom.
let under = self.print_of((self.scroll.0 + self.geom.twips_of(sx), self.scroll.1 + self.geom.twips_of(sy)));
self.fit = false;
if (zoom - self.geom.zoom).abs() > 0.0001 {
self.geom.zoom = zoom;
self.retire_tiles();
self.failed.clear();
}
self.scroll = (under.0 - self.geom.twips_of(ax), under.1 - self.geom.twips_of(ay));
let (under, spare) = (self.tile_of(under), self.slack());
self.scroll = (under.0 - self.geom.twips_of(ax - spare.0), under.1 - self.geom.twips_of(ay - spare.1));
// Keys and menus keep the page's left edge in view.
if let (None, Some(page)) = (at, self.pages.first()) {
self.scroll.0 = self.scroll.0.min(self.tile_of((page.0, page.1)).0);
}
self.clamp_scroll();
log::debug!("lok: zoom {:.0}%", zoom * 100.0);
cx.emit(Zoomed);
+18 -4
View File
@@ -187,8 +187,8 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
.str()
.and_then(|url| Ok((url, unpack.str()?)))
.and_then(|(url, password)| load(lok, &url, password, ""))
.map(|id| Pack::new().u64(id).bytes()),
tag::IMPORT => text::import(lok, unpack).map(|id| Pack::new().u64(id).bytes()),
.and_then(loaded),
tag::IMPORT => text::import(lok, unpack).and_then(loaded),
tag::EXPORT_TEXT => text::export(unpack).map(|()| Vec::new()),
tag::DOC_SIZE => unpack
.u64()
@@ -205,6 +205,7 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
tag::PAINT_TILE => input::painted(unpack),
tag::PAINT_PART => input::part_painted(unpack),
tag::SLIDE_LAYERS => slides::slide_layers(unpack),
tag::SLIDE_LAYER => slides::slide_layer(unpack),
tag::VERIFY => unpack
.str()
.and_then(|url| verify(lok, &url))
@@ -246,9 +247,15 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
.u64()
.and_then(|id| {
let mut number = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
Ok((id, (number()?, number()?, number()?, number()?)))
Ok((id, (number()?, number()?, number()?, number()?), (number()?, number()?)))
})
.and_then(|(id, (x, y, w, h), (pixels, twips))| {
// Calc reads clicks at this scale, not the last tile's.
with_doc(id, |doc| {
doc.set_client_zoom(pixels, twips);
doc.set_visible_area(x, y, w, h)
})
})
.and_then(|(id, (x, y, w, h))| with_doc(id, |doc| doc.set_visible_area(x, y, w, h)))
.map(|()| Vec::new()),
tag::SELECTION_TEXT => unpack
.u64()
@@ -332,6 +339,12 @@ fn load(lok: &Lok, url: &str, password: String, filter: &str) -> io::Result<u64>
Ok(id)
}
/// The reply to a load: the number and LOK_DOCTYPE.
fn loaded(id: u64) -> io::Result<Vec<u8>> {
let kind = with_doc(id, |doc| doc.kind())?;
Ok(Pack::new().u64(id).u64(kind as u64).bytes())
}
/// Stops watching and frees document `id`.
fn close(id: u64) {
let mut docs = docs().lock().expect("the docs lock");
@@ -340,6 +353,7 @@ fn close(id: u64) {
}
docs.remove(&id);
revision::forget(id);
slides::forget(id);
log::info!("lok-worker: {} documents open", docs.len());
}
+60 -9
View File
@@ -1,9 +1,32 @@
use std::io;
use std::{
collections::HashMap,
io,
sync::{LazyLock, Mutex, MutexGuard},
};
use super::with_doc;
use crate::ipc::{self, Pack, Unpack};
use crate::ipc::{Pack, Unpack};
/// Slide `part`'s slideshow layers at about the asked pixels.
/// The most a slide's layers may take in memory.
const LAYER_MEMORY: usize = 512 * 1024 * 1024;
/// The most pixel bytes one reply carries.
const CHUNK: usize = 16 * 1024 * 1024;
/// Rendered layers by document; each leaves as it is read.
type Waiting = HashMap<u64, Vec<Option<Vec<u8>>>>;
fn waiting() -> MutexGuard<'static, Waiting> {
static WAITING: LazyLock<Mutex<Waiting>> = LazyLock::new(Mutex::default);
WAITING.lock().expect("the layers lock")
}
/// Drops document `id`'s waiting layers.
pub(super) fn forget(id: u64) {
waiting().remove(&id);
}
/// Slide `part`'s layers, its pixels held back.
pub(super) fn slide_layers(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
let (id, part) = (unpack.u64()?, unpack.u64()?);
let (width, height) = (unpack.u64()?, unpack.u64()?);
@@ -20,18 +43,46 @@ pub(super) fn slide_layers(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
let hash = slide
.and_then(|slide| slide["hash"].as_str())
.ok_or_else(|| bad("no such slide"))?;
let ((width, height), layers) =
doc.slide_layers((part, hash), (width, height), ipc::MAX_FRAME - 1024)?;
let ((width, height), layers) = doc.slide_layers((part, hash), (width, height), LAYER_MEMORY)?;
let mut body = Pack::new()
.u64(u64::from(width))
.u64(u64::from(height))
.u64(layers.len() as u64);
for layer in &layers {
let mut pixels = Vec::with_capacity(layers.len());
for layer in layers {
body = body.str(&layer.json).u64(u64::from(layer.pixels.is_some()));
if let Some(pixels) = &layer.pixels {
body = body.blob(pixels);
}
pixels.push(layer.pixels);
}
waiting().insert(id, pixels);
Ok(body.bytes())
})?
}
/// Hands over layer `index` from byte `from`; read layers free.
pub(super) fn slide_layer(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
let (id, index, from) = (unpack.u64()?, unpack.u64()?, unpack.u64()?);
let mut waiting = waiting();
let Some((total, bytes)) = piece(waiting.get_mut(&id), index, from) else {
// A request off the batch ends it.
waiting.remove(&id);
let what = format!("no layer {index} from byte {from} waits");
return Err(io::Error::new(io::ErrorKind::InvalidInput, what));
};
if waiting.get(&id).is_some_and(|layers| layers.iter().all(Option::is_none)) {
waiting.remove(&id);
}
Ok(Pack::new().u64(total).blob(&bytes).bytes())
}
/// Up to `CHUNK` bytes of a layer and its length.
fn piece(layers: Option<&mut Vec<Option<Vec<u8>>>>, index: u64, from: u64) -> Option<(u64, Vec<u8>)> {
let slot = layers?.get_mut(usize::try_from(index).ok()?)?;
let pixels = slot.as_ref()?;
let from = usize::try_from(from).ok().filter(|from| *from < pixels.len() || *from == 0)?;
let to = (from + CHUNK).min(pixels.len());
let read = (pixels.len() as u64, pixels[from..to].to_vec());
if to == pixels.len() {
*slot = None;
}
Some(read)
}
+32 -3
View File
@@ -3,7 +3,7 @@
use std::path::PathBuf;
use ely_gpui_component::primitives::Severity;
use ely_ui::commands::{CloseTab, Open, Quit, Save, SaveAs};
use ely_ui::commands::{CloseTab, Open, Save, SaveAs};
use gpui::{App, AppContext as _, Context, PathPromptOptions, TaskExt as _, Window};
use crate::{Dialog, Engine, Host, Opening, Saving, Tabs, in_window, text};
@@ -41,8 +41,37 @@ pub trait Closing: Host {
}
}
fn on_quit(&mut self, _: &Quit, window: &mut Window, cx: &mut Context<Self>) {
self.proceed(After::Quit, window, cx);
/// Takes `window` as home; quit anywhere asks here first.
fn own_window(&mut self, window: &Window, cx: &mut Context<Self>) {
let handle = window.window_handle();
self.core_mut().window = Some(handle);
let host = cx.entity().downgrade();
ely_ui::commands::route_quit(cx, move |cx| {
let host = host.clone();
// The asking window is busy until its action ends.
cx.defer(move |cx| {
let asked = handle.update(cx, |_, window, cx| {
// The question shows in front, with the keys.
window.activate_window();
host.update(cx, |host, cx| {
host.quitting(cx);
host.proceed(After::Quit, window, cx);
})
});
match asked {
Ok(Ok(())) => {}
// No home window is left to ask in.
_ if handle.update(cx, |_, _, _| ()).is_err() => cx.quit(),
Ok(Err(error)) | Err(error) => log::error!("office: the quit was not asked: {error}"),
}
});
});
}
/// A close or quit stopped: no update installs on it.
fn close_stopped(&mut self, cx: &mut Context<Self>) {
ely_ui::updates::quit_stopped(cx);
self.close_cancelled(cx);
}
fn no_document(&self, cx: &mut Context<Self>) {
+3 -3
View File
@@ -87,7 +87,7 @@ pub trait Dialogs: Host {
// Cancel stops the whole close.
let cancelled = move |window: &mut Window, cx: &mut App| {
owner.update(cx, |host, cx| {
host.close_cancelled(cx);
host.close_stopped(cx);
host.dialog_done(window, cx)
})
};
@@ -119,7 +119,7 @@ pub trait Dialogs: Host {
let cancelled = move |window: &mut Window, cx: &mut App| {
owner.update(cx, |host, cx| {
if then.is_some() {
host.close_cancelled(cx);
host.close_stopped(cx);
}
host.dialog_done(window, cx)
})
@@ -135,7 +135,7 @@ pub trait Dialogs: Host {
move |host, _, cx| {
// Reloading ends the close that waited.
if then.is_some() {
host.close_cancelled(cx);
host.close_stopped(cx);
}
host.reload(tab, cx)
},
+2
View File
@@ -72,6 +72,8 @@ pub trait Host: Sized + 'static {
fn observed(&mut self, id: u64, view: &Entity<View<Self>>, cx: &mut Context<Self>);
/// Runs before a search first moves the cursor.
fn search_leaves(&mut self, _: &mut Context<Self>) {}
/// Runs as a quit starts, before any tab closes.
fn quitting(&mut self, _: &mut Context<Self>) {}
/// Runs when a close stops: question or save cancelled.
fn close_cancelled(&mut self, _: &mut Context<Self>) {}
/// An outside change met a save; true if shown.
+25 -11
View File
@@ -77,9 +77,14 @@ impl Engine for Lok {
}
fn restart(app: AppId, worker: &mut Worker) -> io::Result<()> {
*worker = spawn(app)?;
worker.ready()?;
worker.set_author(&ely_platform::user::name()?)
let author = worker.author().map(str::to_string);
let author = author.ok_or_else(|| io::Error::other("the ended worker had no author"))?;
// The ended worker stays until the new one is ready.
let mut fresh = spawn(app)?;
fresh.ready()?;
fresh.set_author(&author)?;
*worker = fresh;
Ok(())
}
fn settled(view: &LokView) -> bool {
@@ -129,10 +134,13 @@ pub fn start(app: AppId, cx: &mut App) -> io::Result<(Arc<Mutex<Worker>>, PathBu
let worker = Arc::new(Mutex::new(spawn(app)?));
// The window opens now; the kernel finishes starting behind it.
let starting = Arc::clone(&worker);
// Settings as read at launch; defaults if unreadable.
let wanted = Arc::new(Mutex::new(ely_ui::settings::current(cx).author.clone()));
let first = Arc::clone(&wanted);
let started = cx.background_executor().spawn(async move {
let mut worker = starting.lock().expect("the worker lock");
worker.ready()?;
worker.set_author(&ely_platform::user::name()?)
sign(&mut worker, &first)
});
cx.spawn(async move |_| {
if let Err(error) = started.await {
@@ -140,10 +148,16 @@ pub fn start(app: AppId, cx: &mut App) -> io::Result<(Arc<Mutex<Worker>>, PathBu
}
})
.detach();
follow_author(&worker, cx);
follow_author(&worker, wanted, cx);
Ok((worker, work))
}
/// Sends the newest wanted name, read under the worker lock.
fn sign(worker: &mut Worker, wanted: &Mutex<String>) -> io::Result<()> {
let author = wanted.lock().expect("the author lock").clone();
worker.set_author(&ely_platform::user::named(&author)?)
}
/// A worker process on the app's profile, not yet checked.
fn spawn(app: AppId) -> io::Result<Worker> {
let data = ely_platform::data_dir(app).map_err(io::Error::other)?;
@@ -154,19 +168,19 @@ fn spawn(app: AppId) -> io::Result<Worker> {
}
/// A new author name signs from the next comment on.
fn follow_author(worker: &Arc<Mutex<Worker>>, cx: &mut App) {
fn follow_author(worker: &Arc<Mutex<Worker>>, wanted: Arc<Mutex<String>>, cx: &mut App) {
let worker = Arc::clone(worker);
let mut signed = ely_ui::settings::current(cx).author.clone();
cx.observe_global::<ely_ui::settings::Current>(move |cx| {
let author = ely_ui::settings::current(cx).author.clone();
if author == signed {
if *wanted.lock().expect("the author lock") == author {
return;
}
signed = author;
let worker = Arc::clone(&worker);
*wanted.lock().expect("the author lock") = author;
let (worker, wanted) = (Arc::clone(&worker), Arc::clone(&wanted));
cx.background_executor()
.spawn(async move {
let set = ely_platform::user::name().and_then(|name| worker.lock().expect("the worker lock").set_author(&name));
// Any order of these tasks ends on the newest name.
let set = sign(&mut worker.lock().expect("the worker lock"), &wanted);
if let Err(error) = set {
log::error!("lok: the new author name is not set: {error}");
}
+2 -2
View File
@@ -190,7 +190,7 @@ pub trait Saving: Host {
}
// No save: a close that waited on it stops.
if then.is_some() {
host.close_cancelled(cx);
host.close_stopped(cx);
}
})
})
@@ -419,7 +419,7 @@ pub trait Saving: Host {
failed => {
self.report(id, failed, cx);
if then.is_some() {
self.close_cancelled(cx);
self.close_stopped(cx);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
//! A failed engine restart keeps the author for retries.
use ely_office::{Engine as _, Lok};
use ely_platform::AppId;
#[test]
fn a_restart_signs_with_the_newest_name_and_survives_a_failure() {
let Ok(install) = ely_lok::install_dir() else {
eprintln!("SKIP: no LOK build");
return;
};
let home = tempfile::tempdir().expect("a temp dir");
// The one test here: nothing else reads HOME.
unsafe { std::env::set_var("HOME", home.path()) };
let profile = ely_platform::data_dir(AppId::Doc).expect("a data dir").join("lok-profile");
std::fs::create_dir_all(&profile).expect("the profile");
let mut worker = ely_lok::Worker::launch(&install, &profile).expect("the worker");
worker.ready().expect("checks");
worker.set_author("Ada").expect("signs");
// The new worker's check cannot write its sample here.
let blocker = profile.join("lok-verify.fodt");
if blocker.exists() {
std::fs::remove_file(&blocker).expect("the last sample goes");
}
std::fs::create_dir(&blocker).expect("a folder in the way");
assert!(Lok::restart(AppId::Doc, &mut worker).is_err(), "the check fails");
assert_eq!(worker.author(), Some("Ada"), "the author survives a failed restart");
std::fs::remove_dir(&blocker).expect("cleared");
Lok::restart(AppId::Doc, &mut worker).expect("the next try restarts");
assert_eq!(worker.author(), Some("Ada"));
// A name asked while down signs after the restart.
worker.shutdown().expect("stops");
assert!(worker.set_author("Latest").is_err(), "a stopped engine cannot take it");
Lok::restart(AppId::Doc, &mut worker).expect("restarts");
assert_eq!(worker.author(), Some("Latest"), "the newest name signs");
}
+2
View File
@@ -13,6 +13,7 @@ fn main() {
"shim/ely_insert.c",
"shim/ely_text.c",
"shim/ely_write.c",
"shim/ely_pen.c",
"shim/ely_place.c",
"shim/ely_annot.c",
"shim/ely_form.c",
@@ -68,6 +69,7 @@ fn main() {
.file("shim/ely_insert.c")
.file("shim/ely_text.c")
.file("shim/ely_write.c")
.file("shim/ely_pen.c")
.file("shim/ely_place.c")
.file("shim/ely_annot.c")
.file("shim/ely_form.c")
+197
View File
@@ -0,0 +1,197 @@
/* The font new text is written in (PDF-03). */
#include <string.h>
#include "ely_shim.h"
/* The script face for typed signatures, from the worker. */
static const unsigned char *script_data;
static size_t script_len;
void ely_script_font(const unsigned char *data, size_t len)
{
script_data = data;
script_len = len;
}
/* `cid` as text extraction reads it (pdf-op-run.c), or -1. */
static int unicode_of(pdf_font_desc *desc, int cid)
{
int ucs[PDF_MRANGE_CAP], n = 0;
if (desc->to_unicode)
n = pdf_lookup_cmap_full(desc->to_unicode, cid, ucs);
if (n == 1 && ucs[0] >= 8 && ucs[0] <= 13)
ucs[0] = ' ';
if (n == 1 && (ucs[0] < 32 || (ucs[0] >= 127 && ucs[0] < 160)))
n = 0;
if (n == 0 && cid < (int)desc->cid_to_ucs_len)
ucs[0] = desc->cid_to_ucs[cid], n = 1;
return n == 1 && ucs[0] > 0 && ucs[0] != FZ_REPLACEMENT_CHARACTER ? ucs[0] : -1;
}
/* Unicode and code pairs of `desc`; its codes are CIDs. */
static void own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
{
pdf_cmap *enc = desc->encoding;
int cid, end;
if (!enc || strcmp(enc->cmap_name, "Identity-H") || enc->codespace_len != 1 || enc->codespace[0].n > 2)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font uses codes Ely cannot write");
p->bytes = enc->codespace[0].n;
end = 1 << (8 * p->bytes);
p->map = fz_malloc_array(ctx, 2 * end, int);
for (cid = 0; cid < end; cid++)
{
int u = unicode_of(desc, cid);
if (u > 0 && pdf_font_cid_to_gid(ctx, desc, cid) > 0)
p->map[2 * p->mapped] = u, p->map[2 * p->mapped + 1] = cid, p->mapped++;
}
}
/* The page font drawn as `own`, with its codes. */
static void own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
{
pdf_obj *res = pdf_page_resources(ctx, page);
pdf_resource_stack stack = { NULL, res };
pdf_obj *fonts = pdf_dict_get(ctx, res, PDF_NAME(Font));
int i, n = pdf_dict_len(ctx, fonts);
for (i = 0; i < n && !p->desc; i++)
{
pdf_font_desc *desc = pdf_load_font(ctx, page->doc, &stack, pdf_dict_get_val(ctx, fonts, i));
if (desc->font != own)
{
pdf_drop_font(ctx, desc);
continue;
}
p->desc = desc;
p->num = pdf_to_num(ctx, pdf_dict_get_val(ctx, fonts, i));
if (!p->num)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font is not an object of its own");
own_codes(ctx, desc, p);
}
if (!p->desc)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the block's font is not a font of its page");
}
void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p)
{
static const struct { const char *choice, *name; } latin[] = {
{ "helvetica", "Helvetica" }, { "times", "Times-Roman" }, { "courier", "Courier" } };
static const struct { const char *choice; int ordering; } cjk[] = {
{ "cjk-sc", FZ_ADOBE_GB }, { "cjk-tc", FZ_ADOBE_CNS }, { "cjk-jp", FZ_ADOBE_JAPAN }, { "cjk-kr", FZ_ADOBE_KOREA } };
size_t i;
if (!strcmp(choice, "script"))
{
if (!script_data)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no script font was given");
p->kind = PEN_CID;
p->font = fz_new_font_from_memory(ctx, "GreatVibes", script_data, (int)script_len, 0, 0);
return;
}
if (!strcmp(choice, "original"))
{
if (!own)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the block has no font of its own");
p->kind = PEN_OWN;
own_font(ctx, page, own, p);
return;
}
for (i = 0; i < nelem(latin); i++)
if (!strcmp(choice, latin[i].choice))
{
p->kind = PEN_SIMPLE;
p->font = fz_new_base14_font(ctx, latin[i].name);
return;
}
for (i = 0; i < nelem(cjk); i++)
if (!strcmp(choice, cjk[i].choice))
{
p->kind = PEN_CJK;
p->ordering = cjk[i].ordering;
p->font = fz_new_cjk_font(ctx, cjk[i].ordering);
return;
}
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no font %s", choice);
}
const char *ely_pen_resource(fz_context *ctx, pdf_document *pdf, pdf_page *page, ely_pen *p, char *name, size_t room)
{
pdf_drop_obj(ctx, p->ref);
p->ref = NULL;
switch (p->kind)
{
case PEN_OWN:
/* Removing the block may drop the font from the page. */
p->ref = pdf_new_indirect(ctx, pdf, p->num, 0);
break;
case PEN_CID:
p->ref = pdf_add_cid_font(ctx, pdf, p->font);
break;
case PEN_SIMPLE:
p->ref = pdf_add_simple_font(ctx, pdf, p->font, PDF_SIMPLE_ENCODING_LATIN);
break;
case PEN_CJK:
p->ref = pdf_add_cjk_font(ctx, pdf, p->font, p->ordering, 0, 0);
break;
}
return ely_add_resource(ctx, pdf, page->obj, PDF_NAME(Font), "ElyF", p->ref, name, room);
}
/* The own font's code for `u`, or -1. */
static int own_code(ely_pen *p, int u)
{
int i;
for (i = 0; i < p->mapped; i++)
if (p->map[2 * i] == u)
return p->map[2 * i + 1];
return -1;
}
/* The new font's glyph for `u`, or 0. */
static int glyph(fz_context *ctx, ely_pen *p, int u)
{
if (p->kind == PEN_SIMPLE && fz_windows_1252_from_unicode(u) < 0)
return 0;
return fz_encode_character(ctx, p->font, u);
}
int ely_pen_has(fz_context *ctx, ely_pen *p, int u)
{
return p->kind == PEN_OWN ? own_code(p, u) >= 0 : glyph(ctx, p, u) > 0;
}
float ely_pen_advance(fz_context *ctx, ely_pen *p, int u, float size)
{
if (p->kind == PEN_OWN)
return pdf_lookup_hmtx(ctx, p->desc, own_code(p, u)).w * size / 1000;
return fz_advance_glyph(ctx, p->font, glyph(ctx, p, u), 0) * size;
}
void ely_pen_code(fz_context *ctx, fz_buffer *buf, ely_pen *p, int u)
{
switch (p->kind)
{
case PEN_OWN:
fz_append_printf(ctx, buf, p->bytes == 1 ? "%02x" : "%04x", own_code(p, u));
break;
case PEN_CID:
fz_append_printf(ctx, buf, "%04x", glyph(ctx, p, u));
break;
case PEN_SIMPLE:
fz_append_printf(ctx, buf, "%02x", fz_windows_1252_from_unicode(u));
break;
case PEN_CJK:
if (u > 0xffff)
fz_append_printf(ctx, buf, "%04x%04x", 0xd800 + ((u - 0x10000) >> 10), 0xdc00 + ((u - 0x10000) & 0x3ff));
else
fz_append_printf(ctx, buf, "%04x", u);
break;
}
}
void ely_drop_pen(fz_context *ctx, ely_pen *p)
{
fz_drop_font(ctx, p->font);
pdf_drop_obj(ctx, p->ref);
pdf_drop_font(ctx, p->desc);
fz_free(ctx, p->map);
}
+30
View File
@@ -53,4 +53,34 @@ int ely_watch_end(fz_context *ctx);
const char *ely_open_warnings(void);
void ely_drop_alt(fz_context *ctx, pdf_document *pdf, pdf_page *page);
/* A font to write with: new, or the page's own. */
typedef enum
{
PEN_CID,
PEN_SIMPLE,
PEN_CJK,
PEN_OWN
} ely_pen_kind;
typedef struct
{
ely_pen_kind kind;
fz_font *font;
int ordering;
pdf_obj *ref;
/* The page's own font: object number, description, code width. */
int num;
pdf_font_desc *desc;
int bytes;
/* Unicode and code pairs of the own font. */
int *map, mapped;
} ely_pen;
void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p);
const char *ely_pen_resource(fz_context *ctx, pdf_document *pdf, pdf_page *page, ely_pen *p, char *name, size_t room);
int ely_pen_has(fz_context *ctx, ely_pen *p, int u);
float ely_pen_advance(fz_context *ctx, ely_pen *p, int u, float size);
void ely_pen_code(fz_context *ctx, fz_buffer *buf, ely_pen *p, int u);
void ely_drop_pen(fz_context *ctx, ely_pen *p);
#endif
+102 -11
View File
@@ -29,6 +29,12 @@ typedef struct
int *c;
int n;
int hit;
/* Page-wide: each target's block, and hits per block. */
int *owner;
int *hits;
/* The glyph each block last counted, so once each. */
int *seen;
int glyph;
} targets;
/* The glyphs of `block` with their user-space origins. */
@@ -58,19 +64,32 @@ static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_ma
{
targets *t = opaque;
fz_point at = fz_transform_point(fz_transform_point(fz_make_point(0, 0), trm), ctm);
int i, k;
int i, k, found = 0;
t->glyph++;
for (i = 0; i < t->n; i++)
{
int match = 0;
if (fabsf(at.x - t->at[i].x) > 0.5f || fabsf(at.y - t->at[i].y) > 0.5f)
continue;
for (k = 0; k < ucslen; k++)
if (ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER)
{
t->hit++;
return 1;
}
for (k = 0; k < ucslen && !match; k++)
match = ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER;
if (!match)
continue;
if (!t->owner)
{
t->hit++;
return 1;
}
/* Every block this glyph matches counts it once. */
if (t->seen[t->owner[i]] != t->glyph)
{
t->seen[t->owner[i]] = t->glyph;
t->hits[t->owner[i]]++;
}
found = 1;
}
return 0;
t->hit += found;
return found;
}
/* Runs the glyph filter over the page; `dry` changes nothing. */
@@ -99,7 +118,7 @@ fz_matrix ely_to_user(fz_context *ctx, pdf_page *page)
/* Whether all of `block` sits in the page's own content. */
static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_block *block)
{
targets t = { NULL, NULL, 0, 0 };
targets t = { NULL, NULL, 0, 0, NULL, NULL, NULL, 0 };
int ok = 0;
fz_var(t.at);
fz_var(t.c);
@@ -122,6 +141,70 @@ static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stex
return ok;
}
/* Each text block's glyphs found in page content, one pass. */
static void reachable_all(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_page *text, int n, int *ok)
{
targets t = { NULL, NULL, 0, 0, NULL, NULL, NULL, 0 };
int *count = NULL, all = 0, b;
fz_matrix to_user = ely_to_user(ctx, page);
fz_stext_block *block;
fz_stext_line *line;
fz_stext_char *ch;
fz_var(t.at);
fz_var(t.c);
fz_var(t.owner);
fz_var(t.hits);
fz_var(t.seen);
fz_var(count);
pdf_begin_operation(ctx, pdf, "Look at text");
fz_try(ctx)
{
for (block = text->first_block; block; block = block->next)
if (block->type == FZ_STEXT_BLOCK_TEXT)
for (line = block->u.t.first_line; line; line = line->next)
for (ch = line->first_char; ch; ch = ch->next)
all++;
t.at = fz_malloc_array(ctx, all + 1, fz_point);
t.c = fz_malloc_array(ctx, all + 1, int);
t.owner = fz_malloc_array(ctx, all + 1, int);
t.hits = fz_calloc(ctx, n + 1, sizeof(int));
t.seen = fz_calloc(ctx, n + 1, sizeof(int));
count = fz_calloc(ctx, n + 1, sizeof(int));
b = 0;
for (block = text->first_block; block; block = block->next)
{
if (block->type != FZ_STEXT_BLOCK_TEXT)
continue;
for (line = block->u.t.first_line; line; line = line->next)
for (ch = line->first_char; ch; ch = ch->next)
if (!(ch->flags & FZ_STEXT_SYNTHETIC))
{
t.at[t.n] = fz_transform_point(ch->origin, to_user);
t.c[t.n] = ch->c;
t.owner[t.n++] = b;
count[b]++;
}
b++;
}
filter_glyphs(ctx, pdf, page, &t, 1);
for (b = 0; b < n; b++)
ok[b] = count[b] > 0 && t.hits[b] == count[b];
}
fz_always(ctx)
{
pdf_abandon_operation(ctx, pdf);
ely_forget_resources(ctx, pdf);
fz_free(ctx, t.at);
fz_free(ctx, t.c);
fz_free(ctx, t.owner);
fz_free(ctx, t.hits);
fz_free(ctx, t.seen);
fz_free(ctx, count);
}
fz_catch(ctx)
fz_rethrow(ctx);
}
/* The text of `block`, lines on lines. */
static void block_text(fz_context *ctx, fz_buffer *buf, fz_stext_block *block)
{
@@ -153,18 +236,24 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
pdf_page *page = NULL;
fz_buffer *buf = NULL, *words = NULL;
fz_output *out = NULL;
int *reached = NULL;
fz_var(text);
fz_var(page);
fz_var(buf);
fz_var(words);
fz_var(out);
fz_var(reached);
fz_try(ctx)
{
pdf_document *pdf = ely_pdf_of(ctx, doc);
fz_stext_block *block;
int first = 1;
int first = 1, n = 0, at = 0;
text = read_text(ctx, doc, number);
page = pdf_load_page(ctx, pdf, number);
for (block = text->first_block; block; block = block->next)
n += block->type == FZ_STEXT_BLOCK_TEXT;
reached = fz_calloc(ctx, n + 1, sizeof(int));
reachable_all(ctx, pdf, page, text, n, reached);
buf = fz_new_buffer(ctx, 1024);
out = fz_new_output_with_buffer(ctx, buf);
fz_write_string(ctx, out, "[");
@@ -175,7 +264,8 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
if (block->type != FZ_STEXT_BLOCK_TEXT)
continue;
ch = block->u.t.first_line->first_char;
editable = upright(block) && reachable(ctx, pdf, page, block);
editable = reached[at++];
editable = editable && upright(block);
fz_drop_buffer(ctx, words);
words = NULL;
words = fz_new_buffer(ctx, 256);
@@ -198,6 +288,7 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
fz_drop_buffer(ctx, words);
fz_drop_page(ctx, (fz_page *)page);
fz_drop_stext_page(ctx, text);
fz_free(ctx, reached);
}
fz_catch(ctx)
return ely_fail(ctx);
+14 -134
View File
@@ -5,22 +5,6 @@
#include "ely_shim.h"
typedef enum
{
PEN_CID,
PEN_SIMPLE,
PEN_CJK
} pen_kind;
/* A font to write with and its resource. */
typedef struct
{
pen_kind kind;
fz_font *font;
int ordering;
pdf_obj *ref;
} pen;
/* What to write, where, in what. */
typedef struct
{
@@ -32,103 +16,6 @@ typedef struct
float box[3];
} text_args;
/* The script face for typed signatures, from the worker. */
static const unsigned char *script_data;
static size_t script_len;
void ely_script_font(const unsigned char *data, size_t len)
{
script_data = data;
script_len = len;
}
/* The pen named `choice`; "original" writes in `own`. */
static void choose_pen(fz_context *ctx, const char *choice, fz_font *own, pen *p)
{
static const struct { const char *choice, *name; } latin[] = {
{ "helvetica", "Helvetica" }, { "times", "Times-Roman" }, { "courier", "Courier" } };
static const struct { const char *choice; int ordering; } cjk[] = {
{ "cjk-sc", FZ_ADOBE_GB }, { "cjk-tc", FZ_ADOBE_CNS }, { "cjk-jp", FZ_ADOBE_JAPAN }, { "cjk-kr", FZ_ADOBE_KOREA } };
size_t i;
if (!strcmp(choice, "script"))
{
if (!script_data)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no script font was given");
p->kind = PEN_CID;
p->font = fz_new_font_from_memory(ctx, "GreatVibes", script_data, (int)script_len, 0, 0);
return;
}
if (!strcmp(choice, "original"))
{
if (!own || !pdf_font_writing_supported(ctx, own))
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font cannot write new text");
p->kind = PEN_CID;
p->font = fz_keep_font(ctx, own);
return;
}
for (i = 0; i < nelem(latin); i++)
if (!strcmp(choice, latin[i].choice))
{
p->kind = PEN_SIMPLE;
p->font = fz_new_base14_font(ctx, latin[i].name);
return;
}
for (i = 0; i < nelem(cjk); i++)
if (!strcmp(choice, cjk[i].choice))
{
p->kind = PEN_CJK;
p->ordering = cjk[i].ordering;
p->font = fz_new_cjk_font(ctx, cjk[i].ordering);
return;
}
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no font %s", choice);
}
/* The pen's font as a resource of `pdf`. */
static void add_pen(fz_context *ctx, pdf_document *pdf, pen *p)
{
switch (p->kind)
{
case PEN_CID:
p->ref = pdf_add_cid_font(ctx, pdf, p->font);
break;
case PEN_SIMPLE:
p->ref = pdf_add_simple_font(ctx, pdf, p->font, PDF_SIMPLE_ENCODING_LATIN);
break;
case PEN_CJK:
p->ref = pdf_add_cjk_font(ctx, pdf, p->font, p->ordering, 0, 0);
break;
}
}
/* The pen's glyph for `u`; 0 when it has none. */
static int glyph(fz_context *ctx, pen *p, int u)
{
if (p->kind == PEN_SIMPLE && fz_windows_1252_from_unicode(u) < 0)
return 0;
return fz_encode_character(ctx, p->font, u);
}
/* `u` as the pen's code, in hex. */
static void code(fz_context *ctx, fz_buffer *buf, pen *p, int u, int gid)
{
switch (p->kind)
{
case PEN_CID:
fz_append_printf(ctx, buf, "%04x", gid);
break;
case PEN_SIMPLE:
fz_append_printf(ctx, buf, "%02x", fz_windows_1252_from_unicode(u));
break;
case PEN_CJK:
if (u > 0xffff)
fz_append_printf(ctx, buf, "%04x%04x", 0xd800 + ((u - 0x10000) >> 10), 0xdc00 + ((u - 0x10000) & 0x3ff));
else
fz_append_printf(ctx, buf, "%04x", u);
break;
}
}
/* `text` as code points; the count is returned. */
static int runes(fz_context *ctx, const char *text, int **out)
{
@@ -142,13 +29,8 @@ static int runes(fz_context *ctx, const char *text, int **out)
return n;
}
static float advance(fz_context *ctx, pen *p, int u, float size)
{
return fz_advance_glyph(ctx, p->font, glyph(ctx, p, u), 0) * size;
}
/* Lines no wider than `width`: [from, to) each. */
static int lay_out(fz_context *ctx, pen *p, const int *u, int n, float size, float width, int *from, int *to,
static int lay_out(fz_context *ctx, ely_pen *p, const int *u, int n, float size, float width, int *from, int *to,
float *widest)
{
int i = 0, lines = 0;
@@ -159,8 +41,9 @@ static int lay_out(fz_context *ctx, pen *p, const int *u, int n, float size, flo
float run = 0, before = 0;
for (j = i; j < n && u[j] != '\n'; j++)
{
float w = advance(ctx, p, u[j], size);
if (run + w > width && j > i)
float w = ely_pen_advance(ctx, p, u[j], size);
/* A box measured from these widths fits them. */
if (run + w > width + 0.01f && j > i)
break;
if (u[j] == ' ')
space = j, before = run;
@@ -257,7 +140,7 @@ pdf_obj *ely_append_content(fz_context *ctx, pdf_document *pdf, pdf_obj *page, f
/* Text laid out: the pen, code points, lines, placement. */
typedef struct
{
pen p;
ely_pen p;
int *u, n;
int *from, *to, lines;
float widest;
@@ -267,21 +150,20 @@ typedef struct
static void drop_laid(fz_context *ctx, laid *l)
{
fz_drop_font(ctx, l->p.font);
pdf_drop_obj(ctx, l->p.ref);
ely_drop_pen(ctx, &l->p);
fz_free(ctx, l->u);
fz_free(ctx, l->from);
fz_free(ctx, l->to);
}
/* Lays `a` out from `first`, or names a missing glyph. */
static int lay_text(fz_context *ctx, text_args *a, fz_font *own, fz_point first, float gap, laid *l)
static int lay_text(fz_context *ctx, text_args *a, fz_font *own, pdf_page *page, fz_point first, float gap, laid *l)
{
int i;
choose_pen(ctx, a->font, own, &l->p);
ely_choose_pen(ctx, a->font, own, page, &l->p);
l->n = runes(ctx, a->text, &l->u);
for (i = 0; i < l->n; i++)
if (l->u[i] != '\n' && !glyph(ctx, &l->p, l->u[i]))
if (l->u[i] != '\n' && !ely_pen_has(ctx, &l->p, l->u[i]))
return l->u[i];
l->from = fz_malloc_array(ctx, l->n + 2, int);
l->to = fz_malloc_array(ctx, l->n + 2, int);
@@ -307,10 +189,7 @@ static void draw_laid(fz_context *ctx, pdf_document *pdf, pdf_page *page, laid *
fz_matrix to_user = ely_to_user(ctx, page);
char name[32];
int i, line;
pdf_drop_obj(ctx, l->p.ref);
l->p.ref = NULL;
add_pen(ctx, pdf, &l->p);
ely_add_resource(ctx, pdf, page->obj, PDF_NAME(Font), "ElyF", l->p.ref, name, sizeof name);
ely_pen_resource(ctx, pdf, page, &l->p, name, sizeof name);
buf = fz_new_buffer(ctx, 256);
fz_append_string(ctx, buf, "q\n");
for (line = 0; line < l->lines; line++)
@@ -320,7 +199,7 @@ static void draw_laid(fz_context *ctx, pdf_document *pdf, pdf_page *page, laid *
((a->rgb >> 16) & 255) / 255.0f, ((a->rgb >> 8) & 255) / 255.0f, (a->rgb & 255) / 255.0f,
tm.a, tm.b, tm.c, tm.d, tm.e, tm.f);
for (i = l->from[line]; i < l->to[line]; i++)
code(ctx, buf, &l->p, l->u[i], glyph(ctx, &l->p, l->u[i]));
ely_pen_code(ctx, buf, &l->p, l->u[i]);
fz_append_string(ctx, buf, "> Tj ET\n");
}
fz_append_string(ctx, buf, "Q\n");
@@ -388,11 +267,12 @@ int ely_put_text(fz_context *ctx, fz_document *doc, int number, int block, const
pdf_document *pdf = NULL;
pdf_page *page = NULL;
ely_shape shape = { NULL };
laid l = { { PEN_SIMPLE, NULL, 0, NULL } };
laid l;
int begun = 0;
fz_var(pdf);
fz_var(page);
fz_var(begun);
memset(&l, 0, sizeof l);
*pixels = NULL;
*grew = 0;
fz_try(ctx)
@@ -409,7 +289,7 @@ int ely_put_text(fz_context *ctx, fz_document *doc, int number, int block, const
first = shape.first;
gap = shape.gap;
}
*missing = lay_text(ctx, &a, shape.font, first, gap, &l);
*missing = lay_text(ctx, &a, shape.font, page, first, gap, &l);
if (!*missing)
{
fz_rect drawn = laid_area(&l, size), region = drawn;
+23 -3
View File
@@ -293,10 +293,30 @@ impl Drop for Worker {
}
}
/// A permission refusal is `PermissionDenied`, named by what it needs.
/// A request the document's permissions refused, by what it needs.
#[derive(Debug)]
pub struct Refused(pub &'static str);
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", crate::proto::may::REFUSED, self.0)
}
}
impl std::error::Error for Refused {}
/// What a permission refusal needs; none for other errors.
pub fn refused(error: &io::Error) -> Option<&'static str> {
error.get_ref()?.downcast_ref::<Refused>().map(|refused| refused.0)
}
/// A worker error; a refusal names a permission it knows.
fn refusal(text: String) -> io::Error {
match text.strip_prefix(crate::proto::may::REFUSED) {
Some(needed) => io::Error::new(io::ErrorKind::PermissionDenied, needed.to_string()),
let named = text
.strip_prefix(crate::proto::may::REFUSED)
.and_then(|needed| crate::proto::may::NAMES.iter().find(|(_, name)| *name == needed));
match named {
Some((_, name)) => io::Error::other(Refused(name)),
None => io::Error::other(text),
}
}
Loaded 100 of 621 files, more files were not shown because too many files have changed in this diff. Show more