Author SHA1 Message Date
Claude bb1f0b2f1f Bug list: ElyTable import, filter and dialog findings; sections ordered
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TycnrQxWJyHRVUSpkoCs7e
2026-10-09 06:36:29 +00:00
Claude 564eb6739d Bug list: ElyShow, LOK bridge and summary table
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TycnrQxWJyHRVUSpkoCs7e
2026-10-09 06:32:28 +00:00
Claude b226581803 Bug list: full static audit of the six first-release apps
docs/BugList.md records the defects found by reading every app and crate,
with Calc and Impress behaviour checked against upstream LibreOffice 26.2.
It explains the three reported problems: ElyTable clicks landing on the
wrong cell (print twips treated as tile twips), ElyShow not repainting on
slide change (no tile invalidation on part switch), and ElyPDF editing
and drawing failures (tiles dropped on every edit, hit order, refusals).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TycnrQxWJyHRVUSpkoCs7e
2026-10-09 06:29:43 +00:00
171 changed files with 1892 additions and 3121 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
# Internal records stay out of source archives.
agents.md export-ignore
CLAUDE.md export-ignore
PRD.md export-ignore
tasks export-ignore
Generated
+17 -17
View File
@@ -1919,7 +1919,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "ely-agent"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"ely-platform",
"ely-ui",
@@ -1927,7 +1927,7 @@ dependencies = [
[[package]]
name = "ely-doc"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"csv",
@@ -1948,7 +1948,7 @@ dependencies = [
[[package]]
name = "ely-files"
version = "0.1.1"
version = "0.1.0"
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=b0dbfd429c17819f6c143fd365852244d497514a#b0dbfd429c17819f6c143fd365852244d497514a"
source = "git+https://github.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=69265b4fd4da888e2d8e1268021af4d358e67afb#69265b4fd4da888e2d8e1268021af4d358e67afb"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -2001,14 +2001,14 @@ dependencies = [
[[package]]
name = "ely-ipc"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"log",
]
[[package]]
name = "ely-lok"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"base64 0.23.1",
"ely-gpui-component",
@@ -2028,7 +2028,7 @@ dependencies = [
[[package]]
name = "ely-mail"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"ely-platform",
"ely-ui",
@@ -2036,7 +2036,7 @@ dependencies = [
[[package]]
name = "ely-mark"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.23.1",
@@ -2058,7 +2058,7 @@ dependencies = [
[[package]]
name = "ely-office"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"directories",
@@ -2078,7 +2078,7 @@ dependencies = [
[[package]]
name = "ely-pdf"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"ely-files",
"ely-gpui-component",
@@ -2094,7 +2094,7 @@ dependencies = [
[[package]]
name = "ely-pdf-engine"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"cc",
"ely-ipc",
@@ -2110,7 +2110,7 @@ dependencies = [
[[package]]
name = "ely-platform"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"arboard",
@@ -2143,7 +2143,7 @@ dependencies = [
[[package]]
name = "ely-show"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"ely-files",
"ely-gpui-component",
@@ -2162,7 +2162,7 @@ dependencies = [
[[package]]
name = "ely-table"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"ely-gpui-component",
"ely-lok",
@@ -2178,7 +2178,7 @@ dependencies = [
[[package]]
name = "ely-tree"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"ely-files",
"ely-gpui-component",
@@ -2196,7 +2196,7 @@ dependencies = [
[[package]]
name = "ely-ui"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"directories",
@@ -2213,7 +2213,7 @@ dependencies = [
[[package]]
name = "ely-web-content"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"cef",
+3 -3
View File
@@ -5,10 +5,10 @@ members = ["apps/*", "crates/*"]
exclude = ["engines", "reference"]
[workspace.package]
version = "0.1.1"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
license = "MIT"
license = "AGPL-3.0-or-later"
publish = false
[workspace.dependencies]
@@ -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 = "b0dbfd429c17819f6c143fd365852244d497514a" }
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "69265b4fd4da888e2d8e1268021af4d358e67afb" }
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"] }
+657 -17
View File
@@ -1,21 +1,661 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2026 Yang Zhang
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.
+1 -1
View File
@@ -757,7 +757,7 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
### 21.1 自有代码默认许可
本 PRD 对新增应用代码采用 MIT 作为发行默认(用户 2026-10-19 定,原为 AGPL-3.0-or-later)。MIT 只管 Ely 的自有代码:链接 MuPDF 的 ElyPDF 发行包整体仍须按 MuPDF 的 AGPL 条款提供对应源码。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1]
本 PRD 对新增应用代码采用 AGPL-3.0-or-later 作为发行默认,便于与 MuPDF 的开源使用路线保持一致。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1]
第三方代码保持原许可证。不得把 LibreOffice、Git 或其他依赖的版权声明统一替换成 Ely 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
+1 -1
View File
@@ -44,4 +44,4 @@ Packages: `packaging/macos/release.sh` writes each app's `.app` and `.dmg`, the
## Licence
MIT (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`.
AGPL-3.0-or-later (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`.
+3 -17
View File
@@ -39,7 +39,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
## 组件映射
依赖 `ely-gpui-component`(pin b0dbfd4,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
依赖 `ely-gpui-component`(pin 69265b4,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
@@ -139,10 +139,7 @@ packaging/icons/build.sh 由 assets/icons 的母版渲染 icns、png、ico
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/engines/*.sh 引擎探测(LOK、MuPDF、libetpan)
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 值与命令数(需辅助功能权限)
@@ -166,14 +163,12 @@ 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 每次构建或测试后都删(用户定:增量缓存会占满磁盘)
@@ -184,7 +179,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
## 决策
- 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)、组件库 b0dbfd4(2026-10-09 起)。
- 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)、组件库 69265b4(2026-10-08 起)。
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并经 SSH 推送(本机 HTTPS keychain 令牌失效)。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。
@@ -262,12 +257,3 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 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`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
+8 -9
View File
@@ -24,18 +24,17 @@ 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 count = |key, n: u64| ely_ui::i18n::counted(cx, key, n, &[("n", &n.to_string())]);
let (words, characters) = (counts.words.to_string(), counts.characters.to_string());
let label = match counts.selected_words {
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(
0 => Self::text(
cx,
"doc.count.words",
&[("words", &words), ("characters", &characters)],
),
selected => Self::text(
cx,
"doc.count.selected",
counts.words,
&[("selected", &selected.to_string()), ("words", &counts.words.to_string())],
&[("selected", &selected.to_string()), ("words", &words)],
),
};
status.right(StatusBarItem::new("doc-count").label(label)).when_some(zoom, |status, zoom| status.right(zoom))
+3 -2
View File
@@ -260,8 +260,9 @@ impl Docs {
let folder = folder.display().to_string();
let text = match written {
Ok(count) => {
let n = count.to_string();
let text = ely_ui::i18n::counted(cx, "doc.merge.done", count as u64, &[("n", &n), ("folder", &folder)]);
let count = count.to_string();
let text =
Docs::text(cx, "doc.merge.done", &[("n", &count), ("folder", &folder)]);
return docs.toast(text, Severity::Success, cx);
}
Err((n, error)) => Docs::text(
-13
View File
@@ -56,19 +56,6 @@ 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,
+2 -12
View File
@@ -116,22 +116,12 @@ a {
list-style: none;
}
/* A text-sized box, centred on the first line. */
.milkdown li[data-item-type='task']::before {
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;
content: '☐ ';
}
.milkdown li[data-item-type='task'][data-checked='true']::before {
border-color: var(--ely-link);
background: var(--ely-link);
box-shadow: inset 0 0 0 0.18em var(--ely-bg);
content: '☑ ';
}
.milkdown hr {
+2 -7
View File
@@ -1,6 +1,5 @@
// 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'
@@ -52,11 +51,6 @@ 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)
@@ -74,7 +68,8 @@ export function faithful(ctx, original) {
if (!blank)
parsed.forEach((node, _, index) => {
gap(last, at[index][0])
nodes.push(kept(node, original.slice(...at[index])) ? node : rawOf(at[index]))
const exact = written([node]) === original.slice(...at[index])
nodes.push(exact ? node : rawOf(at[index]))
places.push(at[index])
last = at[index][1]
})
+2 -14
View File
@@ -1,17 +1,16 @@
// 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'
/** Writing options most of `text` already uses; tables' for remark-gfm. */
/** remark-stringify options most of `text` already uses. */
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).use(remarkGfm).parse(text), (node) => {
visit(unified().use(remarkParse).parse(text), (node) => {
const at = node.position?.start.offset
if (at === undefined) return
const head = text.slice(at, at + 12)
@@ -35,17 +34,6 @@ 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
@@ -1,35 +0,0 @@
// 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
}
+2 -4
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, remarkGFMPlugin } from '@milkdown/kit/preset/gfm'
import { insertTableCommand } from '@milkdown/kit/preset/gfm'
import {
addColumnAfter,
addColumnBefore,
@@ -87,9 +87,7 @@ export async function visual(root, markdown, editable, texts, changed, edge) {
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, markdown)
const { tableCellPadding, tablePipeAlign, ...marks } = style(markdown)
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...marks }))
ctx.set(remarkGFMPlugin.options.key, { tableCellPadding, tablePipeAlign })
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...style(markdown) }))
ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, editable: () => editable }))
})
.use(commonmark)
+4 -3
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_pages("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
view.edit("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
});
cx.notify();
}
@@ -183,7 +183,7 @@ impl Viewer {
written.map(|()| targets.len())
},
move |viewer, done, _, cx| match done {
Ok(count) => viewer.toast_output(ely_ui::i18n::counted(cx, "pdf.split.done", count as u64, &[("count", &count.to_string())]), opened.clone(), cx),
Ok(count) => viewer.toast_output(Self::text(cx, "pdf.split.done", &[("count", &count.to_string())]), opened.clone(), cx),
Err(error) => viewer.failed_write(&stem, &error, cx),
},
cx,
@@ -205,7 +205,8 @@ impl Viewer {
};
let card = match self.ask.as_ref()? {
Ask::Crop { pages, margins, .. } => {
let title = ely_ui::i18n::counted(cx, "pdf.crop.title", pages.len() as u64, &[("count", &pages.len().to_string())]);
let count = pages.len().to_string();
let title = Self::text(cx, "pdf.crop.title", &[("count", &count)]);
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
@@ -1,132 +0,0 @@
//! 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]);
}
}
+9 -31
View File
@@ -17,8 +17,6 @@ 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.
@@ -38,31 +36,20 @@ impl PdfView {
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
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);
self.edit_picking(what, None, op, cx);
}
/// Queues `op`; once it lands, `pick` is picked.
pub fn queue_edit(
pub fn edit_picking(
&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, pages });
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc });
self.next_edit(cx);
}
@@ -70,7 +57,7 @@ impl PdfView {
if self.editing {
return;
}
let Some(Queued { what, op, pick, doc, pages }) = self.queue.pop_front() else {
let Some(Queued { what, op, pick, doc }) = self.queue.pop_front() else {
return;
};
self.editing = true;
@@ -86,7 +73,7 @@ impl PdfView {
view.editing = false;
match fresh {
Ok(fresh) => {
view.refresh(fresh, pages, cx);
view.refresh(fresh, cx);
if let Some(pick) = pick {
view.anchor = pick.first().copied();
view.picked = pick;
@@ -106,22 +93,13 @@ impl PdfView {
}
/// Everything read before the edit goes.
fn refresh(&mut self, fresh: Fresh, pages: bool, cx: &mut Context<Self>) {
fn refresh(&mut self, fresh: Fresh, cx: &mut Context<Self>) {
self.version += 1;
self.edited += 1;
(self.doc, self.mark) = (fresh.doc, fresh.mark);
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;
} else {
// Same pages: old tiles stay under them until repainted.
self.keep_all();
let read = fresh.first.len();
self.sizes[..read].copy_from_slice(&measured[..read]);
}
self.sizes = sizes(fresh.pages, &fresh.first);
self.marks = fresh.marks;
self.retire_tiles();
self.failed.clear();
self.text.clear();
self.asking_text.clear();
@@ -166,6 +144,6 @@ impl PdfView {
pub fn undo(&mut self, redo: bool, cx: &mut Context<Self>) {
let what = if redo { "redo" } else { "undo" };
self.edit_pages(what, move |worker, doc| worker.undo(doc, redo), cx);
self.edit(what, move |worker, doc| worker.undo(doc, redo), cx);
}
}
-1
View File
@@ -1,7 +1,6 @@
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 = ely_ui::i18n::counted(cx, "pdf.ocr.done", pages.len() as u64, &[("read", &read), ("skipped", &skipped)]);
let summary = Self::text(cx, "pdf.ocr.done", &[("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.queue_edit("moving pages", Some(landed), true, move |worker, doc| worker.move_pages(doc, &pages, to), cx);
self.edit_picking("moving pages", Some(landed), 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_pages("deleting pages", move |worker, doc| worker.delete_pages(doc, &pages), cx);
view.edit("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_pages("turning pages", move |worker, doc| worker.rotate_pages(doc, &pages, by), cx);
view.edit("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_pages("inserting a page", move |worker, doc| worker.insert_blank(doc, at + 1, size), cx);
view.edit("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_pages(
view.edit(
"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 = ely_ui::i18n::counted(cx, "pdf.export.images_done", count as u64, &[("count", &count.to_string())]);
let text = Self::text(cx, "pdf.export.images_done", &[("count", &count.to_string())]);
match folder.clone() {
Some(folder) => viewer.toast_output(text, folder, cx),
None => viewer.toast(text, Severity::Success, cx),
+57 -41
View File
@@ -1,6 +1,6 @@
//! Only the pages on screen, drawn in tiles (PDF-01, PERF-05).
use std::sync::Arc;
use std::{collections::HashSet, 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};
use crate::layout::{GAP, Layout, Mode, Placed, layout, visible};
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.
pub(crate) fn span(from: f32, to: f32, sf: f32, side: i32) -> std::ops::RangeInclusive<i32> {
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,13 +69,11 @@ 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(()) => 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;
}),
Ok(()) if !view.read(cx).drawn => {
view.update(cx, |view, _| view.drawn = true);
log::info!("pdf: first tile drawn, page {page}");
}
Ok(()) => {}
Err(error) => log::error!("pdf: drawing a page failed: {error}"),
},
)
@@ -129,14 +127,18 @@ 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 top = -f32::from(self.scroll.offset().y);
let showing = self.showing(sf, dark);
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();
// The current page: first showing more than a sliver.
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;
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;
let now = match self.mode {
Mode::Single => self.current,
// Either page of a pair in view may be current.
@@ -148,33 +150,47 @@ impl PdfView {
cx.emit(Reported);
}
}
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();
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();
self.ask_layers(&pages_shown, cx);
self.ask_notes(pages_shown.iter().copied(), cx);
self.paint(wanted, 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(ely_ui::i18n::counted(cx, "pdf.redact.done", n as u64, &[("name", &shown), ("count", &n.to_string())]), opened.clone(), cx),
Ok(n) => viewer.toast_output(Self::text(cx, "pdf.redact.done", &[("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 = 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 count = regions.len().to_string();
("pdf.redact.title", div().child(Self::text(cx, "pdf.redact.count", &[("count", &count)])).into_any_element())
}
};
let title = Self::text(cx, title, &[("name", &name)]);
-141
View File
@@ -185,144 +185,3 @@ 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");
}
+4 -11
View File
@@ -37,12 +37,10 @@ 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,
/// Tile pixels painted; the first paint is logged (PERF-05).
pub painted: f64,
/// A tile reached the screen; logged once (PERF-05).
pub drawn: bool,
/// 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>,
@@ -145,8 +143,7 @@ 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);
let old = self.backdrop.drain(..).map(|old| old.image);
for image in tiles.chain(old).chain(self.thumbs.drain().map(|(_, image)| image)) {
for image in tiles.chain(self.thumbs.drain().map(|(_, image)| image)) {
images.push(image);
}
images
@@ -167,9 +164,8 @@ impl PdfView {
scroll: ScrollHandle::new(),
tiles: HashMap::new(),
retired: Default::default(),
painted: 0.0,
drawn: false,
failed: HashSet::new(),
backdrop: Vec::new(),
text: HashMap::new(),
asking_text: HashSet::new(),
marks: loaded.marks,
@@ -315,9 +311,6 @@ 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);
}
+2 -2
View File
@@ -292,8 +292,8 @@ impl Deck {
},
move |deck, written, _, cx| match written {
Ok(count) => {
let n = count.to_string();
let text = ely_ui::i18n::counted(cx, "show.output.pictures_done", count as u64, &[("n", &n), ("folder", &shown)]);
let count = count.to_string();
let text = Deck::text(cx, "show.output.pictures_done", &[("n", &count), ("folder", &shown)]);
deck.toast_output(text, opened.clone(), cx);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
+3 -38
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 _, Bounds, Context, Entity, EntityId, IntoElement,
ParentElement as _, Pixels, PlatformDisplay, Window, WindowBounds, WindowOptions,
AnyElement, AnyWindowHandle, App, AppContext as _, Context, Entity, EntityId, IntoElement,
ParentElement as _, PlatformDisplay, Window, WindowBounds, WindowOptions,
};
use super::{Show, audience::Audience, presenter::Presenter};
@@ -80,20 +80,9 @@ 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(bounds(display.bounds())),
window_bounds: Some(WindowBounds::Fullscreen(display.bounds())),
display_id: Some(display.id()),
titlebar: None,
focus: true,
@@ -102,13 +91,6 @@ 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>,
@@ -120,7 +102,6 @@ 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
@@ -128,7 +109,6 @@ 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
@@ -333,18 +313,3 @@ 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);
}
}
+1 -4
View File
@@ -229,10 +229,7 @@ impl Book {
match read {
Ok((sheets, geometry, at)) => {
log::info!("table: tab {id} has {} sheets", sheets.list.len());
view.update(cx, |view, cx| {
view.set_part(sheets.current);
view.set_geometry(geometry.clone(), cx);
});
view.update(cx, |view, _| view.set_part(sheets.current));
let extra = &mut tab.extra;
extra.sheets = sheets;
extra.geometry = geometry;
+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.same", cx);
return book.warn_key("table.columns.one", cx);
}
let other = cx.new(|cx| TextInput::new(window, cx));
book.dialog = Some(Dialog::Columns {
+2 -27
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> = fields(status)
.into_iter()
let parts: Vec<String> = status
.split("; ")
.map(|part| {
let known = part.split_once(": ").and_then(|(name, value)| {
let (_, key) = FIGURES.iter().find(|(known, _)| *known == name)?;
@@ -74,24 +74,6 @@ 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]
@@ -107,13 +89,6 @@ 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, first) = (pick(read.frozen()), pick(read.scrolled_start()));
let (frozen, scroll) = (pick(read.frozen()), pick(read.scroll()));
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(first).max(held);
let mut index = axis.at(frozen + scroll).max(held);
while index < axis.count() && along(axis.start(index)) < length {
take(index);
index += 1;
+10 -6
View File
@@ -114,15 +114,19 @@ pub(crate) fn delete(
let mut detail = if references.formulas + references.names == 0 {
Book::text(cx, "table.delete.alone", &[])
} else {
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)
Book::text(
cx,
"table.delete.refs",
&[
("formulas", &references.formulas.to_string()),
("first", &references.first),
("names", &references.names.to_string()),
],
)
};
if references.dynamic > 0 {
let count = references.dynamic.to_string();
let dynamic = ely_ui::i18n::counted(cx, "table.delete.dynamic", references.dynamic, &[("count", &count)]);
let dynamic = Book::text(cx, "table.delete.dynamic", &[("count", &count)]);
detail = format!("{detail} {dynamic}").into();
}
let label = Book::text(cx, "table.delete.go", &[]);
+1 -2
View File
@@ -95,10 +95,9 @@ impl Tree {
Ask::Force { repo, plan } => {
let lost = match plan.lost.len() {
0 => label("tree.force.nothing"),
n => ely_ui::i18n::counted(
n => text(
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();
Button::new("tree-lines", ely_ui::i18n::counted(cx, key, n as u64, &[("n", &n.to_string())]))
let n = shown.picked.len().to_string();
Button::new("tree-lines", text(cx, key, &[("n", &n)]))
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
+1 -2
View File
@@ -205,10 +205,9 @@ 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 => ely_ui::i18n::counted(
n => text(
cx,
"tree.move.left",
n as u64,
&[("n", &n.to_string()), ("commits", &plan.left.join("\n"))],
),
};
+7 -23
View File
@@ -209,7 +209,13 @@ impl Tree {
.iter()
.filter(|entry| entry.state != State::Ignored)
.count();
let (icon, title) = summary(changed, cx);
let (icon, title) = match changed {
0 => (IconName::CircleCheck, text(cx, "tree.clean", &[])),
n => (
IconName::GitBranch,
text(cx, "tree.changed", &[("n", &n.to_string())]),
),
};
EmptyState::new(("tree-summary", opened.id), icon, title).into_any_element()
}
}
@@ -257,25 +263,3 @@ 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 个文件有改动"]);
});
}
}
+3 -36
View File
@@ -55,16 +55,14 @@ impl Doc {
Ok(Sheets { current, list })
}
/// 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<()> {
/// 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<()> {
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(())
@@ -180,38 +178,7 @@ impl Doc {
.u64(u64::from(pixels.0))
.u64(u64::from(pixels.1))
.bytes();
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())));
}
}
crate::show::layers(&worker.request(tag::SLIDE_LAYERS, &request)?)
}
/// Saves to `url` as `format`; failure surfaces (LOK-09).
+21 -14
View File
@@ -6,7 +6,6 @@ mod analysis;
mod charts;
mod check;
mod commands;
mod filter;
mod format;
mod formula;
mod handouts;
@@ -283,22 +282,30 @@ fn a_production_worker_refuses_run_macro() {
#[cfg(target_os = "macos")]
#[test]
fn an_unpatched_build_is_rejected() {
// 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");
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");
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 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}"
);
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());
}
#[test]
@@ -3,8 +3,6 @@ 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 = [
@@ -333,99 +331,3 @@ 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
@@ -1,154 +0,0 @@
//! 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,20 +360,3 @@ 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,62 +220,3 @@ 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), (256, 3840))
doc.visible_area(&mut worker, (0, 0, 15000, 10000))
.expect("the area");
(dir, worker, doc)
}
-7
View File
@@ -247,13 +247,6 @@ 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,8 +86,6 @@ 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,66 +71,6 @@ 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)
}
@@ -431,29 +371,4 @@ 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");
}
}
+1 -6
View File
@@ -28,7 +28,6 @@ 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;
@@ -90,15 +89,13 @@ pub struct LokView {
fetching: HashSet<(i32, i32)>,
failed: HashSet<((i32, i32), u64)>,
/// The area last told to the kernel.
shown: Option<(Rect, (u32, i32))>,
shown: Option<Rect>,
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>,
@@ -213,7 +210,6 @@ 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),
@@ -389,7 +385,6 @@ impl LokView {
{
self.show(cell, cx);
}
self.follow_graphic(batch.graphic, cx);
self.cursor = batch.cursor;
self.cursor_visible = batch.cursor_visible;
self.selection = batch.selection;
+6 -7
View File
@@ -60,8 +60,8 @@ pub(super) enum Input {
from: (i32, i32),
to: (i32, i32),
},
/// The area shown in twips, and the tile scale.
Visible(Rect, (u32, i32)),
/// The area the view shows, in twips.
Visible(Rect),
/// 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, scale) => doc.visible_area(worker, area, scale),
Input::Visible(area) => doc.visible_area(worker, area),
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,8 +280,7 @@ impl LokView {
count: usize,
modifiers: &Modifiers,
) {
// The kernel reads clicks in tile twips.
let (x, y) = self.tile_point(position);
let (x, y) = self.doc_point(position);
self.send(Input::Mouse(Mouse {
kind,
x,
+3 -9
View File
@@ -23,8 +23,7 @@ pub(super) struct Job {
/// What came back from the lane.
enum Landed {
/// Sequence, whether it edits, whether it switched part, outcome.
Input(u64, bool, bool, (io::Result<Option<String>>, Option<u64>)),
Input(u64, bool, (io::Result<Option<String>>, Option<u64>)),
Tile(Job, io::Result<Tile>),
}
@@ -47,7 +46,6 @@ 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 {
@@ -55,7 +53,7 @@ impl LokView {
} else {
log::debug!("lok: input {seq} waited {waited}, took {took} ms");
}
Landed::Input(seq, edits, switched, outcome)
Landed::Input(seq, edits, outcome)
}
job = tile_queue.next() => {
let Some(job) = job else { break };
@@ -98,12 +96,8 @@ impl LokView {
let mut reported = false;
for landed in batch {
match landed {
Landed::Input(seq, edits, switched, outcome) => {
Landed::Input(seq, edits, 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 {
+17 -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.keep_scroll(|view| view.geom.dpi = dpi);
self.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.keep_scroll(|view| view.geom.zoom = zoom);
self.geom.zoom = zoom;
self.retire_tiles();
self.failed.clear();
}
@@ -249,15 +249,6 @@ impl LokView {
}
}
/// Room beside a narrow document, which sits centred.
pub(super) fn slack(&self) -> (f32, f32) {
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());
@@ -268,10 +259,20 @@ impl LokView {
self.clamp_scroll();
let geom = self.geom;
let (sx, sy) = self.scroll;
// A narrow document sits centered.
let extent = self.extent();
self.spare = self.slack();
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
},
);
let pages = if self.pages.is_empty() {
vec![self.tile_screen_rect((0, 0, extent.0, extent.1))]
vec![self.screen_rect((0, 0, extent.0, extent.1))]
} else {
self.pages
.iter()
@@ -282,11 +283,10 @@ impl LokView {
geom.twips_of(bounds.size.width.into()),
geom.twips_of(bounds.size.height.into()),
);
let scale = (geom.tile_pixels(), TILE_TWIPS);
if self.shown != Some(((sx, sy, vw, vh), scale)) {
self.shown = Some(((sx, sy, vw, vh), scale));
if self.shown != Some((sx, sy, vw, vh)) {
self.shown = Some((sx, sy, vw, vh));
self.failed.clear();
self.send(input::Input::Visible((sx, sy, vw, vh), scale));
self.send(input::Input::Visible((sx, sy, vw, vh)));
}
let (ax, ay) = self.axes();
let side = px(geom.px_of(TILE_TWIPS));
+21 -45
View File
@@ -41,26 +41,18 @@ impl LokView {
self.scroll_by(dx, dy, cx);
}
/// 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();
/// 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;
let (vw, vh) = (
self.geom.twips_of(self.viewport.0) - fx,
self.geom.twips_of(self.viewport.1) - fy,
);
// 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));
}
// 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;
}
@@ -71,7 +63,7 @@ impl LokView {
cx.notify();
}
/// Freezes what lies before `frozen`, in print twips.
/// Freezes what lies before `frozen`, in twips.
pub fn set_frozen(&mut self, frozen: (i32, i32), cx: &mut Context<Self>) {
if self.frozen != frozen {
log::info!("lok: frozen at {frozen:?}");
@@ -83,8 +75,7 @@ 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);
// Clicks land in tile twips.
let (x, y, _, h) = self.tile_rect(rect);
let (x, y, _, h) = rect;
log::info!("lok: reveal at {x},{y}");
for kind in [0, 1] {
let (count, buttons, modifiers) = (1, 1, 0);
@@ -111,19 +102,14 @@ 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 print-twips document point under a view position (LOK-04).
/// The 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();
(
@@ -135,17 +121,14 @@ 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 };
let (fx, fy) = self.frozen_tiles();
(axis(fx, self.scroll.0), axis(fy, self.scroll.1))
(
axis(self.frozen.0, self.scroll.0),
axis(self.frozen.1, self.scroll.1),
)
}
/// 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> {
/// The window point of a document point in twips (LOK-04).
pub fn 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))
@@ -156,19 +139,12 @@ 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,
size: size((end.x - origin.x).max(px(self.geom.px_of(1))), end.y - origin.y),
origin: self.window_point((x, y)),
size: size(px(self.geom.px_of(w.max(1))), px(self.geom.px_of(h))),
})
}
}
-64
View File
@@ -1,64 +0,0 @@
//! 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,6 +359,45 @@ 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,77 +88,3 @@ 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,55 +269,3 @@ 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");
}
-179
View File
@@ -60,182 +60,3 @@ 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| {
view.set_geometry(crate::sheet::geometry(&json).expect("parsed"), cx);
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");
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");
});
});
}
+2 -11
View File
@@ -47,23 +47,14 @@ 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),
};
// 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)));
let under = (self.scroll.0 + self.geom.twips_of(ax), self.scroll.1 + self.geom.twips_of(ay));
self.fit = false;
if (zoom - self.geom.zoom).abs() > 0.0001 {
self.geom.zoom = zoom;
self.retire_tiles();
self.failed.clear();
}
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.scroll = (under.0 - self.geom.twips_of(ax), under.1 - self.geom.twips_of(ay));
self.clamp_scroll();
log::debug!("lok: zoom {:.0}%", zoom * 100.0);
cx.emit(Zoomed);
+2 -10
View File
@@ -205,7 +205,6 @@ 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))
@@ -247,15 +246,9 @@ 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()?), (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)
})
Ok((id, (number()?, number()?, number()?, number()?)))
})
.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()
@@ -347,7 +340,6 @@ fn close(id: u64) {
}
docs.remove(&id);
revision::forget(id);
slides::forget(id);
log::info!("lok-worker: {} documents open", docs.len());
}
+9 -60
View File
@@ -1,32 +1,9 @@
use std::{
collections::HashMap,
io,
sync::{LazyLock, Mutex, MutexGuard},
};
use std::io;
use super::with_doc;
use crate::ipc::{Pack, Unpack};
use crate::ipc::{self, Pack, Unpack};
/// 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.
/// Slide `part`'s slideshow layers at about the asked pixels.
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()?);
@@ -43,46 +20,18 @@ 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), LAYER_MEMORY)?;
let ((width, height), layers) =
doc.slide_layers((part, hash), (width, height), ipc::MAX_FRAME - 1024)?;
let mut body = Pack::new()
.u64(u64::from(width))
.u64(u64::from(height))
.u64(layers.len() as u64);
let mut pixels = Vec::with_capacity(layers.len());
for layer in layers {
for layer in &layers {
body = body.str(&layer.json).u64(u64::from(layer.pixels.is_some()));
pixels.push(layer.pixels);
if let Some(pixels) = &layer.pixels {
body = body.blob(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)
}
-2
View File
@@ -13,7 +13,6 @@ 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",
@@ -69,7 +68,6 @@ 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
@@ -1,197 +0,0 @@
/* 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,34 +53,4 @@ 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
+134 -14
View File
@@ -5,6 +5,22 @@
#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
{
@@ -16,6 +32,103 @@ 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)
{
@@ -29,8 +142,13 @@ 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, ely_pen *p, const int *u, int n, float size, float width, int *from, int *to,
static int lay_out(fz_context *ctx, pen *p, const int *u, int n, float size, float width, int *from, int *to,
float *widest)
{
int i = 0, lines = 0;
@@ -41,9 +159,8 @@ static int lay_out(fz_context *ctx, ely_pen *p, const int *u, int n, float size,
float run = 0, before = 0;
for (j = i; j < n && u[j] != '\n'; j++)
{
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)
float w = advance(ctx, p, u[j], size);
if (run + w > width && j > i)
break;
if (u[j] == ' ')
space = j, before = run;
@@ -140,7 +257,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
{
ely_pen p;
pen p;
int *u, n;
int *from, *to, lines;
float widest;
@@ -150,20 +267,21 @@ typedef struct
static void drop_laid(fz_context *ctx, laid *l)
{
ely_drop_pen(ctx, &l->p);
fz_drop_font(ctx, l->p.font);
pdf_drop_obj(ctx, l->p.ref);
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, pdf_page *page, fz_point first, float gap, laid *l)
static int lay_text(fz_context *ctx, text_args *a, fz_font *own, fz_point first, float gap, laid *l)
{
int i;
ely_choose_pen(ctx, a->font, own, page, &l->p);
choose_pen(ctx, a->font, own, &l->p);
l->n = runes(ctx, a->text, &l->u);
for (i = 0; i < l->n; i++)
if (l->u[i] != '\n' && !ely_pen_has(ctx, &l->p, l->u[i]))
if (l->u[i] != '\n' && !glyph(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);
@@ -189,7 +307,10 @@ 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;
ely_pen_resource(ctx, pdf, page, &l->p, name, sizeof name);
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);
buf = fz_new_buffer(ctx, 256);
fz_append_string(ctx, buf, "q\n");
for (line = 0; line < l->lines; line++)
@@ -199,7 +320,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++)
ely_pen_code(ctx, buf, &l->p, l->u[i]);
code(ctx, buf, &l->p, l->u[i], glyph(ctx, &l->p, l->u[i]));
fz_append_string(ctx, buf, "> Tj ET\n");
}
fz_append_string(ctx, buf, "Q\n");
@@ -267,12 +388,11 @@ 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;
laid l = { { PEN_SIMPLE, NULL, 0, NULL } };
int begun = 0;
fz_var(pdf);
fz_var(page);
fz_var(begun);
memset(&l, 0, sizeof l);
*pixels = NULL;
*grew = 0;
fz_try(ctx)
@@ -289,7 +409,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, page, first, gap, &l);
*missing = lay_text(ctx, &a, shape.font, first, gap, &l);
if (!*missing)
{
fz_rect drawn = laid_area(&l, size), region = drawn;
-97
View File
@@ -275,100 +275,3 @@ fn runs_on_one_baseline_still_wrap_onto_new_lines() {
tops.dedup();
assert!(tops.len() >= 3, "each wrapped line has its own baseline: {tops:?}");
}
#[test]
fn a_subset_font_writes_with_its_own_glyphs_and_reads_back() {
// LibreOffice's export: a subset font whose own tables are placeholders.
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/office/t.pdf");
let mut worker = worker();
let mut doc = worker.open(&path).expect("opens").doc;
let at = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.contains("BUNDLE")).expect("the block");
let mut writing = rewrite(&mut worker, doc, at, "TEXT BUNDLE", "original");
// Room for one line; the space stays a space.
writing.bx[2] *= 2.0;
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("answers"), Wrote::Done(_)), "the page's glyphs suffice");
doc = worker.now(doc).expect("the state");
let now = page_text(&mut worker, doc);
assert!(now.contains("TEXT BUNDLE"), "the new words read back: {now:?}");
}
/// A PDF of `objects`, numbered from 1.
fn assembled(objects: &[Vec<u8>]) -> Vec<u8> {
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (at, body) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend([format!("{} 0 obj\n", at + 1).as_bytes(), body, b"\nendobj\n"].concat());
}
let xref = pdf.len();
pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
offsets.iter().for_each(|at| pdf.extend(format!("{at:010} 00000 n \n").as_bytes()));
pdf.extend(format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
pdf
}
/// "AB" in WinAnsi Helvetica whose ToUnicode maps only "A".
fn partial_unicode_pdf() -> Vec<u8> {
let stream = |data: &[u8]| [format!("<</Length {}>>stream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
let unicode = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /U def\n1 begincodespacerange <00> <FF> endcodespacerange\n1 beginbfchar <41> <0041> endbfchar\nendcmap CMapName currentdict /CMap defineresource pop end end";
assembled(&[
b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Resources<</Font<</F1 4 0 R>>>>/Contents 5 0 R>>".to_vec(),
b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding/ToUnicode 6 0 R>>".to_vec(),
stream(b"BT /F1 24 Tf 72 700 Td (AB) Tj ET"),
stream(unicode),
])
}
/// "AyB", where "x" and "y" share one glyph.
fn shared_glyph_pdf() -> Vec<u8> {
let font = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts/GreatVibes-Regular.ttf")).expect("the font");
let stream = |dict: &str, data: &[u8]| [format!("<<{dict}/Length {}>>stream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
let unicode = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /U def\n1 begincodespacerange <0000> <FFFF> endcodespacerange\n4 beginbfchar <0001> <0041> <0002> <0078> <0003> <0079> <0004> <0042> endbfchar\nendcmap CMapName currentdict /CMap defineresource pop end end";
let gids: Vec<u8> = [0u16, 36, 16, 16, 37].iter().flat_map(|g| g.to_be_bytes()).collect();
let objects = [
b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Resources<</Font<</F1 4 0 R>>>>/Contents 5 0 R>>".to_vec(),
b"<</Type/Font/Subtype/Type0/BaseFont/GreatVibes/Encoding/Identity-H/DescendantFonts[6 0 R]/ToUnicode 7 0 R>>".to_vec(),
stream("", b"BT /F1 24 Tf 72 700 Td <000100030004> Tj ET"),
b"<</Type/Font/Subtype/CIDFontType2/BaseFont/GreatVibes/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>/FontDescriptor 8 0 R/CIDToGIDMap 9 0 R/DW 600>>".to_vec(),
stream("", unicode),
b"<</Type/FontDescriptor/FontName/GreatVibes/Flags 4/FontBBox[-200 -300 1200 1000]/ItalicAngle 0/Ascent 900/Descent -300/CapHeight 700/StemV 80/FontFile2 10 0 R>>".to_vec(),
stream("", &gids),
stream(&format!("/Length1 {}", font.len()), &font),
];
assembled(&objects)
}
#[test]
fn a_letter_that_shares_its_glyph_keeps_its_own_code() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("shared.pdf");
std::fs::write(&path, shared_glyph_pdf()).expect("writes");
let mut worker = worker();
let mut doc = worker.open(&path).expect("opens").doc;
assert_eq!(page_text(&mut worker, doc), "AyB", "the sample reads as written");
let writing = rewrite(&mut worker, doc, 0, "ByA", "original");
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("answers"), Wrote::Done(_)));
doc = worker.now(doc).expect("the state");
assert_eq!(page_text(&mut worker, doc), "ByA", "y stays y, not the glyph's other letter");
}
#[test]
fn a_partial_to_unicode_falls_back_as_reading_does() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("partial.pdf");
std::fs::write(&path, partial_unicode_pdf()).expect("writes");
let mut worker = worker();
let mut doc = worker.open(&path).expect("opens").doc;
assert_eq!(page_text(&mut worker, doc), "AB", "the sample reads as written");
let writing = rewrite(&mut worker, doc, 0, "BA", "original");
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("answers"), Wrote::Done(_)), "B is in the font");
doc = worker.now(doc).expect("the state");
let out = dir.path().join("saved.pdf");
worker.save(doc, &out).expect("saves");
let doc = worker.open(&out).expect("reopens").doc;
assert_eq!(page_text(&mut worker, doc), "BA", "saved and read again");
}
@@ -61,28 +61,3 @@ fn text_a_field_and_page_order_read_back_elsewhere() {
assert!(pages[1].0.contains("Changed words") && !pages[1].0.contains("First page"), "{pages:?}");
assert_eq!(pages[1].1, vec!["Ada".to_string()]);
}
#[test]
fn text_in_a_subset_font_reads_back_elsewhere() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/office/t.pdf");
let mut worker = worker();
let mut doc = worker.open(&path).expect("opens").doc;
let blocks = worker.text_blocks(doc, 0).expect("blocks");
let (block, read) = blocks.iter().enumerate().find(|(_, b)| b.text.contains("BUNDLE")).expect("the block");
let writing = Writing {
block: Some(block),
expected: read.text.clone(),
text: "TEXT BUNDLE".into(),
font: "original".into(),
size: read.size,
rgb: read.rgb,
bx: [read.rect[0], read.rect[1], (read.rect[2] - read.rect[0]) * 2.0],
};
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
doc = worker.now(doc).expect("the state");
let out = dir.path().join("edited.pdf");
worker.save(doc, &out).expect("saves");
let pages = read_back(&out);
assert!(pages[0].0.contains("TEXT BUNDLE"), "{pages:?}");
}
+1 -1
View File
@@ -9,7 +9,7 @@ fn log_lines_reach_the_app_file_and_a_second_init_fails() {
log::logger().flush();
let text = std::fs::read_to_string(dir.path().join("ely.log")).unwrap();
assert!(text.contains(&format!("ElyDoc {} started", env!("CARGO_PKG_VERSION"))), "{text}");
assert!(text.contains("ElyDoc 0.1.0 started"), "{text}");
assert!(text.contains("hello from the test"), "{text}");
let error = logging::init_in(AppId::Mail, dir.path()).unwrap_err();
+6 -71
View File
@@ -134,21 +134,6 @@ pub fn install(cx: &mut App) {
let tag = locale_tag(cx);
let locale = resolve(&tag);
log::info!("i18n: locale {tag} -> {locale}");
install_as(locale, cx);
}
/// Installs the catalogs speaking `locale` (en-US or zh-CN).
pub fn install_as(locale: &'static str, cx: &mut App) {
let (en, zh) = catalogs();
cx.set_global(
I18n::new(locale)
.catalog("en-US", &en)
.catalog("zh-CN", &zh),
);
}
/// The English and Chinese catalogs, every module's messages.
fn catalogs() -> (Vec<(&'static str, &'static str)>, Vec<(&'static str, &'static str)>) {
let en: Vec<_> = EN
.iter()
.chain(office::EN)
@@ -197,7 +182,11 @@ fn catalogs() -> (Vec<(&'static str, &'static str)>, Vec<(&'static str, &'static
.chain(settings::ZH_CN)
.copied()
.collect();
(en, zh)
cx.set_global(
I18n::new(locale)
.catalog("en-US", &en)
.catalog("zh-CN", &zh),
);
}
/// `ELY_LOCALE`, then the language setting, then the system's.
@@ -214,63 +203,9 @@ pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
cx.global::<I18n>().text(key, args)
}
/// The message for `n` things: `key.one` when one, else `key`.
pub fn counted(cx: &App, key: &str, n: u64, args: &[(&str, &str)]) -> SharedString {
text(cx, &count_key(key, n), args)
}
fn count_key(key: &str, n: u64) -> String {
match n {
1 => format!("{key}.one"),
_ => key.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::{catalogs, count_key, resolve};
use std::collections::BTreeSet;
#[test]
fn one_thing_takes_the_singular() {
assert_eq!(count_key("tree.changed", 1), "tree.changed.one");
for n in [0, 2, 21] {
assert_eq!(count_key("tree.changed", n), "tree.changed", "{n}");
}
}
#[gpui::test]
fn counted_reads_one_and_many_in_both_languages(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
super::install_as("en-US", cx);
let said = |n: u64, cx: &gpui::App| super::counted(cx, "tree.changed", n, &[("n", &n.to_string())]);
assert_eq!((said(1, cx), said(2, cx)), ("1 file changed".into(), "2 files changed".into()));
super::install_as("zh-CN", cx);
assert_eq!((said(1, cx), said(2, cx)), ("1 个文件有改动".into(), "2 个文件有改动".into()));
});
}
#[test]
fn every_english_singular_reads_unlike_its_plural_at_one() {
let (en, _) = catalogs();
let text = |key: &str| en.iter().find(|(k, _)| *k == key).map(|(_, v)| *v).expect("the key");
for (key, one) in en.iter().filter(|(key, _)| key.ends_with(".one")) {
let many = text(key.trim_end_matches(".one")).replace("{n}", "1").replace("{count}", "1");
assert_ne!(*one, many, "{key} reads like its plural");
assert!(!one.contains("{n}") && !one.contains("{count}"), "{key} names its one");
}
}
#[test]
fn both_catalogs_hold_the_same_keys_and_singulars() {
let (en, zh) = catalogs();
let keys = |list: &[(&str, &str)]| list.iter().map(|(key, _)| key.to_string()).collect::<BTreeSet<_>>();
let (en, zh) = (keys(&en), keys(&zh));
assert_eq!(en.symmetric_difference(&zh).collect::<Vec<_>>(), Vec::<&String>::new());
for key in en.iter().filter_map(|key| key.strip_suffix(".one")) {
assert!(en.contains(key), "{key}.one has no plural");
}
}
use super::resolve;
#[test]
fn zh_variants_pick_zh_cn_others_pick_en() {
+2 -2
View File
@@ -70,7 +70,7 @@ pub(super) const EN: &[(&str, &str)] = &[
("table.columns.space", "Space"),
("table.columns.other", "Other"),
("table.columns.go", "Split"),
("table.columns.same", "Select cells in one column."),
("table.columns.one", "Select cells in one column."),
("table.filter.title", "Column {column}"),
("table.filter.up", "Sort A to Z"),
("table.filter.down", "Sort Z to A"),
@@ -230,7 +230,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("table.columns.space", "空格"),
("table.columns.other", "其他"),
("table.columns.go", "分列"),
("table.columns.same", "请选同一列中的单元格。"),
("table.columns.one", "请选同一列中的单元格。"),
("table.filter.title", "{column} 列"),
("table.filter.up", "升序排序"),
("table.filter.down", "降序排序"),
-10
View File
@@ -36,7 +36,6 @@ pub(super) const EN: &[(&str, &str)] = &[
("pdf.side.marks", "Bookmarks"),
("pdf.page.label", "Page {n}"),
("pdf.crop.title", "Crop {count} pages"),
("pdf.crop.title.one", "Crop 1 page"),
("pdf.crop.top", "Top"),
("pdf.crop.right", "Right"),
("pdf.crop.bottom", "Bottom"),
@@ -46,7 +45,6 @@ pub(super) const EN: &[(&str, &str)] = &[
("pdf.split.every", "Pages per file"),
("pdf.split.go", "Choose Folder…"),
("pdf.split.done", "{count} files written"),
("pdf.split.done.one", "1 file written"),
("pdf.extract.taken", "{name} is open; choose another name"),
("pdf.extract.done", "Pages written to {name}"),
("pdf.font.original", "The document's font"),
@@ -169,7 +167,6 @@ pub(super) const EN: &[(&str, &str)] = &[
"pdf.ocr.done",
"Recognized {read} pages; skipped {skipped}.",
),
("pdf.ocr.done.one", "Recognized 1 page; skipped {skipped}."),
("pdf.mark.add", "Add Bookmark"),
("pdf.mark.rename", "Rename Bookmark"),
("pdf.mark.delete", "Delete Bookmark"),
@@ -198,13 +195,11 @@ pub(super) const EN: &[(&str, &str)] = &[
"pdf.redact.count",
"{count} marked regions are removed for good.",
),
("pdf.redact.count.one", "1 marked region is removed for good."),
("pdf.redact.tail", "redacted"),
(
"pdf.redact.done",
"Saved {name}: {count} regions removed and checked.",
),
("pdf.redact.done.one", "Saved {name}: 1 region removed and checked."),
("pdf.clean.open", "Clean Document…"),
("pdf.clean.title", "Clean {name}"),
("pdf.clean.meta", "Properties and metadata ({count})"),
@@ -259,7 +254,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("pdf.side.marks", "书签"),
("pdf.page.label", "第 {n} 页"),
("pdf.crop.title", "裁剪 {count} 页"),
("pdf.crop.title.one", "裁剪 1 页"),
("pdf.crop.top", "上"),
("pdf.crop.right", "右"),
("pdf.crop.bottom", "下"),
@@ -269,7 +263,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("pdf.split.every", "每个文件的页数"),
("pdf.split.go", "选择文件夹…"),
("pdf.split.done", "已写入 {count} 个文件"),
("pdf.split.done.one", "已写入 1 个文件"),
("pdf.extract.taken", "{name} 已打开,请换一个名称"),
("pdf.extract.done", "页面已写入 {name}"),
("pdf.font.original", "文档自带字体"),
@@ -377,7 +370,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("pdf.ocr.replace", "重新识别,替换 Ely 此前的文字层"),
("pdf.ocr.reading", "正在识别第 {n} 页,共 {count} 页"),
("pdf.ocr.done", "识别了 {read} 页,跳过 {skipped} 页。"),
("pdf.ocr.done.one", "识别了 1 页,跳过 {skipped} 页。"),
("pdf.mark.add", "添加书签"),
("pdf.mark.rename", "重命名书签"),
("pdf.mark.delete", "删除书签"),
@@ -400,13 +392,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("pdf.redact.title", "删除 {name} 中标记的内容"),
("pdf.redact.none", "请先标记要删除的文字或区域。"),
("pdf.redact.count", "将彻底删除 {count} 处标记。"),
("pdf.redact.count.one", "将彻底删除 1 处标记。"),
("pdf.redact.tail", "已删除"),
(
"pdf.redact.done",
"已保存 {name}:删除并核对了 {count} 处。",
),
("pdf.redact.done.one", "已保存 {name}:删除并核对了 1 处。"),
("pdf.clean.open", "清理文档…"),
("pdf.clean.title", "清理 {name}"),
("pdf.clean.meta", "属性与元数据({count})"),
-2
View File
@@ -44,7 +44,6 @@ pub(super) const EN: &[(&str, &str)] = &[
"show.output.pictures_done",
"Wrote {n} pictures to {folder}",
),
("show.output.pictures_done.one", "Wrote 1 picture to {folder}"),
("show.output.failed", "Output failed: {error}"),
("show.check.menu", "Check Presentation…"),
("show.check.title", "Presentation Check"),
@@ -98,7 +97,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("show.output.taken", "{name} 已存在,未写入任何文件"),
("show.output.done", "已导出 {name}"),
("show.output.pictures_done", "已写入 {n} 张图片到 {folder}"),
("show.output.pictures_done.one", "已写入 1 张图片到 {folder}"),
("show.output.failed", "输出失败:{error}"),
("show.check.menu", "检查演示…"),
("show.check.title", "演示检查"),
-2
View File
@@ -126,7 +126,6 @@ pub(super) const EN: &[(&str, &str)] = &[
("pdf.export.tail", "optimized"),
("pdf.export.done", "Saved {name}."),
("pdf.export.images_done", "Saved {count} page images."),
("pdf.export.images_done.one", "Saved 1 page image."),
(
"pdf.export.optimized_done",
"Saved {name}: {before} → {after}.",
@@ -247,7 +246,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("pdf.export.tail", "已优化"),
("pdf.export.done", "已保存 {name}。"),
("pdf.export.images_done", "已保存 {count} 张页面图片。"),
("pdf.export.images_done.one", "已保存 1 张页面图片。"),
(
"pdf.export.optimized_done",
"已保存 {name}:{before} → {after}。",
+2 -12
View File
@@ -33,17 +33,12 @@ pub(super) const EN: &[(&str, &str)] = &[
),
(
"table.delete.refs",
"{formulas} on other sheets, first {first}, and {names} refer to it; they will show #REF!.",
"{formulas} formulas on other sheets, first {first}, and {names} named ranges refer to it; they will show #REF!.",
),
("table.delete.formulas", "{n} formulas"),
("table.delete.formulas.one", "1 formula"),
("table.delete.names", "{n} named ranges"),
("table.delete.names.one", "1 named range"),
(
"table.delete.dynamic",
"{count} formulas or names use INDIRECT; what they point to is known only when they run, so they may break too.",
),
("table.delete.dynamic.one", "1 formula or name uses INDIRECT; what it points to is known only when it runs, so it may break too."),
("table.delete.go", "Delete"),
("table.cells.rows_above", "Insert Rows Above"),
("table.cells.rows_below", "Insert Rows Below"),
@@ -124,17 +119,12 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
),
(
"table.delete.refs",
"其他工作表上有 {formulas}(第一个在 {first})和 {names}引用它;删除后它们显示 #REF!。",
"其他工作表上有 {formulas} 个公式(第一个在 {first})和 {names} 个命名区域引用它;删除后它们显示 #REF!。",
),
("table.delete.formulas", "{n} 个公式"),
("table.delete.formulas.one", "1 个公式"),
("table.delete.names", "{n} 个命名区域"),
("table.delete.names.one", "1 个命名区域"),
(
"table.delete.dynamic",
"另有 {count} 个公式或名称用 INDIRECT,指向何处要运行时才知道,删除后也可能出错。",
),
("table.delete.dynamic.one", "另有 1 个公式或名称用 INDIRECT,指向何处要运行时才知道,删除后也可能出错。"),
("table.delete.go", "删除"),
("table.cells.rows_above", "在上方插入行"),
("table.cells.rows_below", "在下方插入行"),
+2 -14
View File
@@ -2,13 +2,8 @@
pub(super) const EN: &[(&str, &str)] = &[
("menu.tools", "Tools"),
("doc.count.words", "{words}, {characters}"),
("doc.count.words", "{words} words, {characters} characters"),
("doc.count.selected", "{selected} of {words} words"),
("doc.count.selected.one", "{selected} of 1 word"),
("doc.count.word", "{n} words"),
("doc.count.word.one", "1 word"),
("doc.count.character", "{n} characters"),
("doc.count.character.one", "1 character"),
("office.find.open", "Find and Replace"),
("office.find.stale", "Edited. Enter counts again."),
("doc.spell.ignore_all", "Ignore All"),
@@ -55,7 +50,6 @@ pub(super) const EN: &[(&str, &str)] = &[
("doc.merge.unreadable", "{name} cannot be read: {error}"),
("doc.merge.exists", "{name} exists. Nothing was written."),
("doc.merge.done", "Wrote {n} files to {folder}"),
("doc.merge.done.one", "Wrote 1 file to {folder}"),
(
"doc.merge.failed",
"The merge stopped at record {n}: {error}",
@@ -64,13 +58,8 @@ pub(super) const EN: &[(&str, &str)] = &[
pub(super) const ZH_CN: &[(&str, &str)] = &[
("menu.tools", "工具"),
("doc.count.words", "{words},{characters}"),
("doc.count.words", "{words} 字,{characters} 字符"),
("doc.count.selected", "已选 {selected} / {words} 字"),
("doc.count.selected.one", "已选 {selected} / 1 字"),
("doc.count.word", "{n} 字"),
("doc.count.word.one", "1 字"),
("doc.count.character", "{n} 字符"),
("doc.count.character.one", "1 字符"),
("office.find.open", "查找和替换"),
("office.find.stale", "已改动,回车重新计数"),
("doc.spell.ignore_all", "全部忽略"),
@@ -114,6 +103,5 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("doc.merge.unreadable", "无法读取 {name}:{error}"),
("doc.merge.exists", "{name} 已存在,未写入任何文件。"),
("doc.merge.done", "已写入 {n} 个文件到 {folder}"),
("doc.merge.done.one", "已写入 1 个文件到 {folder}"),
("doc.merge.failed", "合并在第 {n} 条停止:{error}"),
];
-6
View File
@@ -17,7 +17,6 @@ pub(super) const EN: &[(&str, &str)] = &[
("tree.clone.busy", "A clone is already running."),
("tree.clean", "Nothing to commit"),
("tree.changed", "{n} files changed"),
("tree.changed.one", "1 file changed"),
("tree.no_git", "Git is not available"),
("tree.unreadable", "This repository could not be read"),
("tree.branch.detached", "Detached at {oid}"),
@@ -63,9 +62,7 @@ pub(super) const EN: &[(&str, &str)] = &[
("tree.layout.unified", "Unified"),
("tree.layout.split", "Side by Side"),
("tree.lines.stage", "Stage {n} Lines"),
("tree.lines.stage.one", "Stage 1 Line"),
("tree.lines.unstage", "Unstage {n} Lines"),
("tree.lines.unstage.one", "Unstage 1 Line"),
("tree.hunk.stage", "Stage Hunk"),
("tree.hunk.unstage", "Unstage Hunk"),
("tree.open_file", "Open File"),
@@ -147,7 +144,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("tree.clone.busy", "已有克隆正在进行。"),
("tree.clean", "没有要提交的改动"),
("tree.changed", "{n} 个文件有改动"),
("tree.changed.one", "1 个文件有改动"),
("tree.no_git", "Git 不可用"),
("tree.unreadable", "无法读取此仓库"),
("tree.branch.detached", "游离于 {oid}"),
@@ -187,9 +183,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("tree.layout.unified", "行内"),
("tree.layout.split", "并排"),
("tree.lines.stage", "暂存 {n} 行"),
("tree.lines.stage.one", "暂存 1 行"),
("tree.lines.unstage", "取消暂存 {n} 行"),
("tree.lines.unstage.one", "取消暂存 1 行"),
("tree.hunk.stage", "暂存此块"),
("tree.hunk.unstage", "取消暂存此块"),
("tree.open_file", "打开文件"),
-4
View File
@@ -46,7 +46,6 @@ pub(super) const EN: &[(&str, &str)] = &[
"tree.force.lost",
"These {n} commits on the remote would be overwritten:\n{commits}",
),
("tree.force.lost.one", "This commit on the remote would be overwritten:\n{commits}"),
("tree.stash.open", "Stash Changes…"),
("tree.stash.title", "Stash changes"),
("tree.stash.message", "Message"),
@@ -132,7 +131,6 @@ pub(super) const EN: &[(&str, &str)] = &[
"tree.move.left",
"These {n} commits leave it; the reflog still finds them:\n{commits}",
),
("tree.move.left.one", "This commit leaves it; the reflog still finds it:\n{commits}"),
("tree.clean.open", "Clean Untracked Files…"),
("tree.clean.title", "Clean untracked files"),
("tree.clean.ignored", "Include ignored files"),
@@ -188,7 +186,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
),
("tree.force.nothing", "远端没有本分支缺少的提交。"),
("tree.force.lost", "远端这 {n} 个提交会被覆盖:\n{commits}"),
("tree.force.lost.one", "远端这 1 个提交会被覆盖:\n{commits}"),
("tree.stash.open", "储藏改动…"),
("tree.stash.title", "储藏改动"),
("tree.stash.message", "说明"),
@@ -268,7 +265,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
"tree.move.left",
"这 {n} 个提交将离开该分支,引用日志里仍可找回:\n{commits}",
),
("tree.move.left.one", "这 1 个提交将离开该分支,引用日志里仍可找回:\n{commits}"),
("tree.clean.open", "清理未跟踪的文件…"),
("tree.clean.title", "清理未跟踪的文件"),
("tree.clean.ignored", "包括被忽略的文件"),
+631
View File
@@ -0,0 +1,631 @@
# 缺陷清单
2026-10-09 对首版六个应用(ElyDoc、ElyTable、ElyShow、ElyPDF、ElyMark、ElyTree)与其共用 crate 的全量静态审查。逐行读了 `apps/` 六个应用、`crates/` 全部源码(含 C 垫片与 ElyMark 网页脚本);Calc 与 Impress 的内核行为对照上游 LibreOffice `libreoffice-26-2` 分支源码核实(`sc/source/ui/view/*.cxx`、`sd/source/ui/view/*.cxx`、`desktop/source/lib/init.cxx`)。审查容器里没有引擎与组件库,没有运行与构建;凡标“已确认”都是沿调用链读到底的结论,“可能”是路径成立但依赖未能运行核实的条件。
编号前缀:T ElyTable,S ElyShow,P ElyPDF,D ElyDoc,M ElyMark,G ElyTree,K LOK 桥(`crates/ely-lok`),X 共用外壳(`ely-office`、`ely-files`、`ely-ui`、`ely-platform`、`ely-ipc`)。严重度:致命=崩溃、丢数据或功能不可用;高=常见路径结果错或功能坏;中=边缘路径坏或界面误导;低=小问题。
## 统计
| 节 | 致命 | 高 | 中 | 低 | 合计 |
| --- | --- | --- | --- | --- | --- |
| T ElyTable 与 LOK 画布 | 1 | 1 | 7 | 6 | 15 |
| S ElyShow | 2 | 0 | 2 | 6 | 10 |
| P ElyPDF | 1 | 3 | 4 | 6 | 14 |
| D ElyDoc | 0 | 2 | 2 | 4 | 8 |
| M ElyMark | 0 | 1 | 5 | 8 | 14 |
| G ElyTree | 0 | 0 | 3 | 11 | 14 |
| K LOK 桥 | 0 | 1 | 1 | 2 | 4 |
| X 共用外壳与平台 | 1 | 2 | 4 | 7 | 14 |
| 合计 | 5 | 10 | 28 | 50 | 93 |
致命:T-01 内核的图块与命中按像素网格,Ely 的光标、选区、表头按文档 twips,越远越错位;S-01 切换幻灯片后主画布不重画;S-03 ElyShow 的缩放一两帧后弹回“适合幻灯片”,缩放功能不可用;P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃;X-01 设置窗口在前时 Cmd+Q 直接退出,跳过未保存询问并抹掉恢复点。
高:T-02 单元格光标露出边缘即把视图滚到中央,点击与拖选时视图跳动;D-01 导出或打印对话框开着时关闭其标签即崩溃;D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录;P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失;P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图;P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应;M-01 键入期间外部以相同内容重写文件,丢掉上次停顿之后键入的全部文字;K-01 非 macOS 的 worker 不运行 VCL 主循环,内核的回调与异步输入永远不送达;X-02 共用 `settings.json` 读不出时 Doc、Table、Show 根本起不来;X-03 第二个实例启动即清掉运行中实例的工作目录与恢复点。
## 用户报告的三个问题
### ElyTable:点一个单元格,光标跳到别处;拖不出多格选区
根因是 T-01:Ely 把 Calc 的“打印 twips”(文档坐标)与“图块 twips”(按内核像素网格对齐的坐标)当成同一套。内核画图块与命中测试都按它自己每行每列取整后的像素网格(`ScViewData::ToPixel` 截断),而单元格光标、选区、表头几何都以文档 twips 报回;越往右下,两套坐标相差越多,到第 300 行左右相差整整一行。点击落在内核的像素网格上(选中的是鼠标下画出的那格),但光标框与表头按文档 twips 画,出现在相邻的格上,看起来就是“跳到别处”。T-02 让部分可见的格一被点中就把视图滚半屏,拖选到边缘时内容在指针下移动,选区随之错位。T-03、T-04 是同一组问题的两个侧面。
### ElyShow:换幻灯片后主视图不变,要拉一下窗口才刷新
根因是 S-01:Impress 在 `SwitchPage` 期间压掉一切图块作废通知(`sd::Window::LogicInvalidate` 里 `IsInSwitchPage()` 直接返回),只发 `LOK_CALLBACK_SET_PART`;`LokView` 收到 `SET_PART` 只加计数,不作废图块,`Input::Part` 落地时也不作废。旧幻灯片的图块一直显示,直到窗口尺寸变化让适合缩放变了、全部图块退役重画。
### ElyPDF:编辑不工作、画不出
鼠标、命中与坐标换算本身是对的(`pages.rs` 按工具分派到 `press`/`drag_to`/`release` 或 `sketch_*`,`page_at`/`from_view` 与垫片 `placed()` 互逆)。失败来自状态与时序:P-02 每次编辑落地都清掉全部图块与缩略图,页面先变成空白纸再一块一块重画,刚画的墨迹要等所在图块重画后才出现,重的页面上像“什么也没画出来”;P-05 上一笔还在落地时结束的下一笔被拒并提示“文档已改动”,连续手绘会丢笔画;P-03 “编辑文字和图片”先命中图片再命中文字,整页背景图或扫描件上永远点不到文字,点击稍一抖动就成了移动背景图;P-04 该工具的层读取在每个文字块上重跑一遍整页内容过滤,密集页面要等很久,等待期间点击被静默忽略;P-06 字体默认“原字体”,嵌入 Type 1/Type 3 字体的文件一应用就报原始引擎错误。另有 P-01:写到无权限目录等任何 `PermissionDenied` 都被拼成不存在的文案键,直接崩溃。
## T ElyTable 与 LOK 画布(Calc)
### T-01 内核的图块与命中按像素网格,Ely 的光标、选区、表头按文档 twips,越远越错位
- 严重度:致命 | 置信度:已确认(上游源码核实)
- 位置:`crates/ely-lok/src/view/scroll.rs:112`(`doc_point` 线性换算)、`crates/ely-lok/src/view/render.rs:275-330`(图块按 `TILE_TWIPS` 线性排布)、`apps/ely-table/src/grid.rs:96-121`(表头按 `place()` 线性换算)、`crates/ely-lok/src/view.rs:382-387`(单元格光标按打印 twips 直接用)
- 现象:滚到几百行之后点一格,名称框显示的地址是鼠标下画出的那格,但光标框画在相邻行列上,表头标号也对不上;拖选时选区高亮与格线错开;行高非默认(如 0.2 cm)的表十几行就错一行。
- 原因:上游 `ScGridWindow::PaintTile`(`sc/source/ui/view/gridwin4.cxx:1459-1600`)把图块起点换成像素(`fTilePosXPixel = nTilePosX * nOutputWidth / nTileWidth`),再用 `ScPositionHelper`/`ScBoundsProvider` 在**累加取整的像素**网格里找首行首列,以该列的像素起点为原点画;`ScModelObj::postMouseEvent`(`docuno.cxx:852`)把 twips 乘 `GetPPTX()` 成像素后 `GetPosFromPixel`(`viewdata.cxx:2858`)逐行 `ToPixel` 累加命中。默认行高 256 twips 取整成 17 像素(真值 17.07),每行差 0.067 像素,约 255 行差一行;默认列宽 1280 twips 取整成 85 像素,约 256 列差一列。而 `sc_print_twips_msgs` 下 CELL_CURSOR、TEXT_SELECTION、`SheetGeometryData` 都是文档 twips,上游专门有 `ScViewData::GetPrintTwipsPosFromTileTwips`(`viewdata.cxx:2637`)做两套坐标的换算,Collabora Online 的客户端也以 `SheetGeometry` 在两者之间换算;Ely 没有这一步。
- 复现:新建工作簿,键入 1 回车三百次(或打开任何几百行的 xlsx),滚到第 300 行,点 A300:光标框落在 A301 附近,左侧表头标的是 299;拖选 A300:C305,高亮与格线错开。
- 修法:以 `.uno:SheetGeometryData` 为准在客户端建像素网格(按内核同样的 `ToPixel` 截断累加),把鼠标与可见区从文档 twips 换成图块 twips 再发给内核,把 CELL_CURSOR、选区、引用标记从文档 twips 换回图块 twips 再画;并调用 `setClientZoom` 让内核的 PPTX 与 Ely 的图块比例一致(见 T-04)。
### T-02 单元格光标露出边缘即把视图滚到中央,点击与拖选时视图跳动
- 严重度:高 | 置信度:已确认
- 位置:`crates/ely-lok/src/view/scroll.rs:45-64`(`show`),调用处 `crates/ely-lok/src/view.rs:372-387`
- 现象:点一个只露出一半的格(右缘或底缘),整张表横向跳半屏、纵向跳三分之二屏;拖选拖到边缘时内容在指针下滑走,选区落错位置。
- 原因:`show()` 对任何不完全可见的矩形都重置滚动:`scroll.0 = x - fx - vw / 2`、`scroll.1 = y - fy - vh / 3`,不是最小滚动量;而 `apply_batch` 对每次光标或单元格光标变化都调它。
- 复现:把窗口缩到只露出 A 到 H 列,点半露的 H 列;或从 A1 向右下拖选到视口边缘。
- 修法:`show()` 只滚到刚好露出目标(`scroll = x + w - vw` 或 `x`),与一般表格软件一致;拖选期间不因选区变化滚动。
### T-03 视口比工作表宽时网格先居中再左移一次
- 严重度:中 | 置信度:已确认
- 位置:`crates/ely-lok/src/view/render.rs:243-253`(`spare` 居中)、`crates/ely-lok/src/view/scroll.rs:99-108`(`extent`)
- 现象:宽屏上打开工作簿,表格先出现在视口中央、两侧是空底色,内核按 `setClientVisibleArea` 扩大平铺区并报 `DOCUMENT_SIZE_CHANGED` 后整张表再跳到左边;窗口每次拉宽都重现一次。
- 原因:`prepare` 把窄文档居中(`slack(viewport.0, extent.0)`),这对 Writer 页面合理,对 Calc 的网格不合理:Calc 的文档尺寸只是已用区加余量(上游 `GetTiledRenderingArea`),随可见区增长。
- 修法:Calc 文档不居中(`spare = (0, 0)`),或以 `reach` 先把 `extent` 撑到至少一个视口宽。
### T-04 从不调用 `setClientZoom`,内核命中与几何按默认缩放算
- 严重度:中 | 置信度:已确认
- 位置:`crates/ely-lok/src/ffi/document.rs`(无对应调用)、`crates/ely-lok/src/view/zoom.rs:44-62`(缩放只改本地 `geom`)
- 现象:Ely 缩放到 125% 或 200% 后,内核的 PPTX 仍是 100% 的 1/15,`getDocumentSize`、`GetPosFromPixel` 的像素取整都按 100% 网格算;与 T-01 叠加,缩放越大错位越明显(图块按 Ely 的比例画,命中按内核的比例算,两套像素网格的取整不同)。
- 原因:上游 `ScModelObj::setClientZoom`(`docuno.cxx:1100`)是客户端告知像素/twips 比例的唯一入口,Collabora Online 在载入与每次缩放时都调;Ely 的 `Document` 没有包装它。
- 修法:载入与每次 `zoom_at` 后发 `setClientZoom(tile_pixels, tile_pixels, TILE_TWIPS, TILE_TWIPS)`。
### T-05 文档尺寸按内核像素取整,表尾被裁
- 严重度:低 | 置信度:已确认(上游源码核实)
- 位置:`crates/ely-lok/src/view.rs:360-368`(`doc_size` 直接取 `getDocumentSize`)
- 现象:`getDocumentSize`(上游 `docuno.cxx:740-780`)返回“像素累加后再除 PPTX”的 twips(85 像素×15=1275 twips 一列,而不是 1280),平铺区越大短得越多;滚动到最右最下时最后一列、最后几行露不全。
- 修法:以几何(`SheetGeometryData`)算 Calc 的文档范围,不用 `getDocumentSize`。
### T-06 滚轮向下被内核平铺区卡住
- 严重度:低 | 置信度:已确认
- 位置:`crates/ely-lok/src/view/scroll.rs:20-28`(`clamp_scroll` 以 `extent` 为界)、`render.rs:264-268`(可见区在绘制时才发)
- 现象:快速向下滚动空白区时每次只能滚到当前文档底,内核收到可见区后再扩 25 行,下一次滚动才能继续;连续滚动一顿一顿。
- 修法:Calc 下 `extent` 至少放到 `scroll + 2 个视口`,或滚动时提前发可见区。
### T-07 表头点选、行高列宽拖动是“按编号的命令”,而每次点击与拖选都推进修订,紧接着的表头操作被当作过期拒绝
- 严重度:中 | 置信度:已确认(时序窗口为一次后台读取)
- 位置:`crates/ely-lok/src/commands/sheet.rs:154-164`(`select.column`/`select.row` 的 `Col`/`Row` 在 `NUMBERED` 内,`commands.rs:63-72`)、`crates/ely-office/src/lok.rs:187-202`(`run_in` 对编号命令取 `read_at` 发 EXPECT)、`crates/ely-lok/src/worker/revision.rs:51-66`(鼠标按下、松开、按键拖动都 `bump`)、`apps/ely-table/src/grid.rs:204-211`、`:306-327`、`apps/ely-table/src/book.rs:199-246`(`reread` 之后 `read_at` 才更新)
- 现象:点一格(或拖选、键入)后马上点列头或行头、或拖完列边:命令带着上一次读取的修订发 EXPECT,内核修订已被鼠标事件推进,回 `stale:`,弹“文档在此间已改动,请重试”,整列没有选中。`read_at` 还没来得及由 `reread` 更新时(刚打开工作簿、worker 忙于画图块时)必现。
- 原因:列号、行号不会被光标移动改变,EXPECT 的保护在这里是多余的;而所有鼠标按下与松开(含拖选的每一步移动)都计入修订。
- 修法:`select.column`、`select.row`、`columns.width`、`rows.height` 不作编号命令(或 `run_in` 对它们不带 `at`),只保留对工作表、对象、参考线等真会被改动重排的编号做 EXPECT。
### T-08 自动筛选的列菜单对光标所在的表而不是按钮所在的表筛选、排序
- 严重度:中 | 置信度:可能(内核侧按补丁追到;取决于单元格光标在哪)
- 位置:`apps/ely-table/src/data.rs:412-437`(`column_asked` 发 `data.filter`/`data.sort`)、`:379-409`(`ask_column`);内核 `patches/libreoffice/0017-lok-calc-filter-validation-columns.patch` 的 `lcl_Query`(`rView.GetDBData()`)与 `SID_SORT` 路径(`GetDBData()`)
- 现象:单元格光标在筛选区之外(刚在别处键入)时点列头的筛选箭头、勾选值或“升序”:要么弹内核错误(`Check::Undoable` 失败),要么筛选、排序了光标所在的另一块数据。菜单本身能开,因为 `.uno:AutoFilterValues?col=&row=` 用的是按钮格。
- 原因:内核自己的菜单对按钮格 `GetDBAtCursor(nCol, nRow)` 操作,补丁后的 `LaunchAutoFilterMenu` 不移动光标;Ely 的答复却经 `GetDBData()`(当前光标或选区)解析。
- 修法:`column_asked` 先 `cell.goto` 到 `address(column, row)`(`Dialog::Column` 记下 `row`),或让补丁按按钮格取数据区。
### T-09 导入文本文件时整文件的 UTF-8 校验在界面线程跑
- 严重度:中 | 置信度:已确认
- 位置:`apps/ely-table/src/text.rs:241-245`(`in_window` 里 `Pending::new`)、`:58-63`、`:108-116`(`guess_encoding` 对全文 `from_utf8`)
- 现象:打开几百 MB 的 CSV,导入对话框出现前整窗冻结几百毫秒到数秒(树莓派更久)。
- 原因:读字节在后台,`Pending::new`(编码与分隔符猜测)却在主线程闭包里。
- 修法:猜测放进后台任务,`in_window` 只接现成的 `Pending`。
### T-10 CSV 导入对话框开着时打开任何别的对话框,待导入的文件静默消失
- 严重度:中 | 置信度:已确认
- 位置:`apps/ely-table/src/text.rs:254-262`(`next_import` 把 `Pending` 移进 `Dialog::Import`);无条件 `self.dialog = Some(..)` 的:`apps/ely-table/src/output.rs:218`、`rules.rs:146`、`:278`、`:341`、`analysis.rs:123`、`:149`、`page.rs:146`、`protect.rs:61`、`formulas.rs:119`、`:209`、`data.rs:231`、`:317`、`:397`
- 现象:访达双击 .csv 出现导入对话框,按 Cmd+E 再取消:`Pending`(已检查字节的唯一副本)被丢,文件再也不打开,无提示。
- 修法:替换 `Dialog::Import` 时把它放回 `imports` 队头,或有对话框开着时拒绝再开。
### T-11 对话框比其标签活得久:导出、定义名称、重命名不可见却“开着”,挡住导入;名称表的“转到”激活已关闭的标签
- 严重度:低 | 置信度:可能
- 位置:`apps/ely-table/src/dialog.rs:644-648`(导出 `sheet_in(*tab)?`)、`:563-571`、`:542-545`;`apps/ely-table/src/text.rs:255-257`(`next_import` 要 `dialog.is_none()`);`apps/ely-table/src/formulas.rs:255-258`(`core.active = tab` 不核对)
- 现象:Cmd+E 后 Cmd+W 关掉该标签:对话框不画了但 `self.dialog` 仍为 `Some`,无法关闭,之后打开 .csv 没反应,直到再开关一次别的对话框;名称表的“转到”把 `core.active` 指到已关标签,正文显示“没有打开的文档”。ElyDoc 的 D-01 是同一类问题(那里直接崩溃)。
- 修法:`Book` 实现 `Host::tab_closed` 清掉指向该标签的 `dialog` 与 `choices`;“转到”先核对 `tab(tab).is_some()`。
### T-12 打印区域是整列或整行时页面设置无法应用
- 严重度:中 | 置信度:可能(内核以 `$A:$D` 格式化整列区域)
- 位置:`apps/ely-table/src/page.rs:75-83`(`cell_ok` 要求字母加数字)、`:86-97`(`areas`)、`:169-176`(`page_asked` 以 `table.page.bad_reference` 拒绝)
- 现象:Excel 文件的打印区域 `=Sheet1!$A:$D` 预填进对话框,只改方向或缩放再按应用也被拒“Write ranges like A1:D20…”;不重写区域就用不了页面设置。
- 修法:`areas()` 接受 `COL:COL` 与 `ROW:ROW`(内核 `ParseAny` 本就接受),或内核给的原值免校验。
### T-13 打印失败报成“导出失败”(非 macOS 必现)
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-table/src/output.rs:147-150`(`print` 把 `print_later` 的失败交给 `output_failed`,文案 `table.export.failed`)、`crates/ely-platform/src/print.rs:87-89`
- 修法:单独的 `table.print.failed` 文案;平台未实现时禁用或说明。
### T-14 导入扫描把整个文件的每个字段都建成字符串
- 严重度:低 | 置信度:已确认(影响随文件大小)
- 位置:`apps/ely-table/src/text.rs:78-106`(`scan`)、`:158-173`(`split` 返回全部行的 `Vec<Vec<String>>`)
- 现象:只需要 8 行预览与每列的类型标志,却为 200 MB 的 CSV 分配约 1–2 GB 堆,树莓派上可能耗尽内存。
- 修法:`split` 改为迭代器,只留前 `SHOWN` 行与列标志。
### T-15 非 UTF-8 文本的默认编码跟系统区域而不是界面语言
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-table/src/text.rs:236`(`i18n::resolve(&locale::system())`)
- 现象:英文系统上把应用语言设成简体中文,打开 GB18030 的 CSV 默认仍是 Windows-1252(反之亦然),与 agents.md 的“中文界面 GB18030、英文界面 Windows-1252”不符。
- 修法:用 `ely_ui::i18n` 解析后的当前语言。
## S ElyShow(Impress)
### S-01 切换幻灯片后主画布不重画
- 严重度:致命 | 置信度:已确认(上游源码核实)
- 位置:`crates/ely-lok/src/events.rs:274`(`SET_PART` 只加 `cell.sheets`)、`crates/ely-lok/src/view/input.rs:231-235`(`show_part` 只排队 `Input::Part`)、`crates/ely-lok/src/view/lane.rs:95-124`(`Input` 落地不作废图块)、`apps/ely-show/src/slides.rs:244-247`、`apps/ely-show/src/deck.rs:308-314`(只在母版视图切换时 `invalidate`)
- 现象:在左侧缩略图条点另一张幻灯片(或经批注栏、演示检查跳转),缩略图高亮变了,主画布仍是上一张;拖一下窗口边才变。
- 原因:上游 `DrawViewShell::SwitchPage`(`sd/source/ui/view/drviews1.cxx:1003-1012`)只发 `LOK_CALLBACK_SET_PART`;切换期间 `sd::Window::LogicInvalidate`(`sd/source/ui/view/sdwindow.cxx:1016-1020`)因 `IsInSwitchPage()` 直接返回,不发 INVALIDATE_TILES。Collabora Online 的图块请求自带 `part`,切页即换缓存;Ely 的 `paint_tile` 不带 part,图块缓存按坐标键保留,所以旧页一直显示。窗口尺寸变化时 `fit_zoom` 改了缩放、`retire_tiles` 才重画。Calc 切表经 `SetTabNo` 走 `PaintGrid` 会发作废,所以 ElyTable 没有这个症状。
- 复现:新建演示,“幻灯片›新建”,点第一张缩略图。
- 修法:`LokView` 在 `Input::Part` 落地(或收到 `SET_PART`)时 `retire_tiles()` 并清 `failed`,或把 part 编进图块键。
### S-02 `deck.rs` 超过 500 行
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-show/src/deck.rs`(506 行)
- 原因:违反规则 5(文件不超过 500 行);工作区内只有这一个文件超限。
- 修法:把 `reread` 的读取闭包或 `Extra` 的图片管理拆到独立模块。
### S-03 ElyShow 的缩放一两帧后弹回“适合幻灯片”,缩放功能不可用
- 严重度:致命 | 置信度:已确认
- 位置:`apps/ely-show/src/deck.rs:139-150`(`Host::observed` 每次都 `view.fit_page(cx)`)、`crates/ely-office/src/open.rs:253`(每个 `Reported` 都调 `observed`)、`crates/ely-lok/src/view/lane.rs:121` 与 `view.rs:413`(任何输入落地、任何内核事件批次都发 `Reported`)、`crates/ely-lok/src/view.rs:262-268`(`fit_page` 置 `fit = true`)、`crates/ely-lok/src/view/zoom.rs:51`(`zoom_at` 置 `fit = false`)、`render.rs:256`、`:286-289`
- 现象:Cmd+=、Cmd+-、Cmd+滚轮、捏合、状态栏缩放控件、“视图”菜单:幻灯片放大一帧,随即跳回适合页面,状态栏百分比也跳回。agents.md 写的“手动缩放即停止适合”不成立。
- 原因:缩放后重绘改变了可见区,排队 `Input::Visible`,落地即 `Reported` → `observed` → 重新 `fit_page`;没有可见区变化时下一批内核事件也一样。
- 复现:打开任一演示,Cmd+=。
- 修法:只在标签首次观察时适合一次(`Extra` 里记标志),`observed` 不再调 `fit_page`;Cmd+0 已由 `Zooming::zoom(Reset)` 处理。
### S-04 选屏对话框被别的对话框顶掉后,`show_starting` 卡住、放映副本泄漏
- 严重度:中 | 置信度:可能(多屏才会到选屏对话框)
- 位置:`apps/ely-show/src/slideshow/start.rs:180-183`(置 `show_starting`、开对话框)、`:289-310`(只有取消与开始两条路清理)、无条件覆盖 `self.dialog` 的:`apps/ely-show/src/new.rs:298`、`apps/ely-show/src/table.rs:24-28`、`apps/ely-show/src/remarks.rs:80`、`apps/ely-show/src/chart.rs:453-454`;守卫 `start.rs:123-125` 只记日志
- 现象:两块屏,F5 出选屏对话框时按 Cmd+N(或插入›表格、批注、图表)顶掉它;此后 F5、Shift+F5、放映菜单都没反应也无提示,`show-N.odp` 副本留在内核与磁盘里直到下次启动。同样的覆盖也会丢掉打了一半的批注或图表数据。
- 修法:任何 `ask_*` 替换对话框时(或 `watch_dialogs` 发现种类离开 `Dialog::Show`)跑与取消相同的清理;或有对话框开着时拒绝再开。
### S-05 一条批注的时间恰为午夜或只有日期,整个批注栏不显示
- 严重度:中 | 置信度:可能(上游 `utl::toISO8601` 时分秒全零时不写时间部分;jiff 的 `civil::DateTime` 要求有时间)
- 位置:`apps/ely-show/src/remarks.rs:28-36`(逐条 `parse::<jiff::civil::DateTime>()?`)、`:168-176`(一条失败整栏换成错误文字);内核 `sd/source/ui/unoidl/unomodel.cxx` 以 `utl::toISO8601` 写 `dateTime`
- 现象:含 00:00:00 批注(LibreOffice “去除个人信息”保存的文件、只写 `dc:date` 的工具)的演示,右栏只显示“a bad comment time 2025-03-01: …”,画布上的标记还在但栏内无法编辑删除。ElyDoc 的 D-04 是同一类问题。
- 修法:无时间部分时按 `civil::Date` 解析,单条失败只影响该条。
### S-06 导出或打印的“从–到”填 0 时导出全部幻灯片
- 严重度:低 | 置信度:可能
- 位置:`apps/ely-show/src/output/dialog.rs:82`(`value.round() as u32`,无下限)、`apps/ely-show/src/output.rs:53-59`(`pages()` 只排序)、`crates/ely-lok/src/worker.rs:403-430`(`from == 0` 表示全部)、`apps/ely-show/src/output.rs:117-125`(`targets` 的 `number as usize - 1`,0 时溢出:发行构建回绕、调试构建 panic)
- 修法:`set` 里夹到 `1..=last`,`pages()` 再校验。
### S-07 母版视图下演示检查的行按幻灯片序号跳转
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-show/src/check.rs:184-194`(`show_part(part)` 用幻灯片序号)、`:112`(`check_show` 无母版视图守卫,`slides.rs:93-99` 有)
- 现象:显示母版时点“第 3 张”,内核对母版列表 `setPart(2)`,显示的是第 3 个母版或空白,对话框关闭。
- 修法:先退出母版视图,或母版视图下禁用行。
### S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢
- 严重度:低 | 置信度:可能
- 位置:`apps/ely-show/src/notes.rs`(`notes.at` 在聚焦期间冻结在上次读完的修订;`run_on(tab, &at, "notes.set", …)` 是编号命令,EXPECT 拒绝过期修订;强制重读后内核旧备注覆盖输入框,而 `shown` 已置为键入文字)
- 现象:编辑幻灯片后在后台重读完成前点进备注框键入、失焦:提示“文档在此间已改动,请重试”,键入的备注被旧备注换掉;再失焦不再发送(`text == shown`)。
- 修法:聚焦时取视图的 `read_at()` 更新 `notes.at`;发送失败时让 `shown` 保持内核文字以便下次失焦重发。
### S-09 改尺寸失败后 `size_asked` 一直留着
- 严重度:低 | 置信度:可能
- 位置:`apps/ely-show/src/design.rs:62-64`(命令前置 `size_asked`)、`apps/ely-show/src/deck.rs:343-347`(只在读回 0.5 mm 内才清);用它的 `deck.rs:71-74`、`apps/ely-show/src/insert.rs:144`、`apps/ely-show/src/media.rs:116`、`apps/ely-show/src/guides.rs:200`、`:318`
- 现象:`slide.size` 失败(worker 错误、无撤销步)后宽高字段显示内核没接受的尺寸,新参考线、媒体按它定位裁剪。`transition_asked` 有落地判定,尺寸没有。
- 修法:命令报错时清掉,或像切换那样按序号落定。
### S-10 开始放映的两处拒绝无提示(规则 2)
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-show/src/slideshow/start.rs:194-198`(标签或屏幕已不在 → 只记日志并 `discard`;选屏对话框开着时关掉标签再按开始可达)、`:123-125`(正在启动 → 只记日志)
- 修法:两处都弹提示。
## P ElyPDF(MuPDF)
### P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃
- 严重度:致命 | 置信度:已确认(文案层缺键即失败)
- 位置:`apps/ely-pdf/src/view.rs:123-126`(`restricted` 拼 `pdf.may.{needed}`)、`:228`(`fail` 对一切 `PermissionDenied` 都走 `restricted(&error.to_string())`)、`apps/ely-pdf/src/protect.rs:315`、`apps/ely-pdf/src/ask.rs:507-509`
- 现象:导出、提取、拆分、签名、保护、附件另存写到不可写文件夹,或任何 OS 权限错误,不显示错误而是崩溃。
- 原因:`restricted` 假定错误文字是 worker 的 `restricted:<名>`(`client.rs:297-302`),但 `ely_files::export`→`save_atomic` 保留 OS 的错误类别,`holder()`→`same_file`→`canonicalize` 同样,`annotate()` 里的 `user::name()` 读设置也可能是 `PermissionDenied`;得到的键 `pdf.may.Permission denied (os error 13)` 不在任何表里。
- 复现:文件›导出… 到只读文件夹。
- 修法:只在文字是 `may::NAMES` 之一时走 `restricted`,否则走 `pdf.failed`/`office.error.save`;或给 worker 的拒绝单独的错误类型。
### P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失
- 严重度:高 | 置信度:已确认
- 位置:`apps/ely-pdf/src/edits.rs:102-111`(`refresh` 清 `tiles`、`thumbs`)、`apps/ely-pdf/src/pages.rs:272-279`(每帧只请求一块)
- 现象:任何编辑(一笔手绘、勾一个复选框、填一个字段)后可见页面变成空白纸,再以每帧一块 512 像素的速度填回;期间刚加的批注看不见;缩略图也全部重画。图多的页面上每次编辑要几秒,感觉“画了没反应”。
- 原因:LOK 画布作废时旧图继续显示到新图到达(agents.md 已定),PDF 视图没有照做:`retire_tiles()` 直接清空 `self.tiles`,`page_view` 画 `bg(paper)` 加空的 `tiles`。
- 修法:旧图块保留到替换到达时才退役(`paint()` 的 `tiles.insert` 处已这样做),缩略图同。
### P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图
- 严重度:高 | 置信度:已确认
- 位置:`apps/ely-pdf/src/content.rs:172-187`(命中顺序)、`:206`(1 点阈值)
- 现象:整页背景图、扫描件、包裹整页的表单 XObject 上,点任何文字选中的都是图片(面板显示替换/删除);按下与松开相差 1 个页面点(适合宽度下约 1 像素)即成“移动对象”编辑,把背景图挪走。图下的文字永远编辑不到。
- 原因:`layer.drawn.iter().rposition(|d| inside(..))` 先于文字块查找,不看目标大小;`release()` 的阈值是页面点不是窗口像素。
- 修法:对象整页或包住文字块时优先文字块;移动阈值按窗口像素(如 3 像素)。
### P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应
- 严重度:高 | 置信度:可能(代价推断,未计时)
- 位置:`crates/ely-pdf-engine/shim/ely_text.c:227-245`、`:156-179`、`:133-145`(每块一次 `pdf_filter_page_contents`);`apps/ely-pdf/src/content.rs:129-156`(逐页读)、`:169-171`(层未到即静默返回)
- 现象:密集页面上开编辑工具很久不出框,点击被忽略,图块与缩略图也停住(worker 单线程被占满)。
- 修法:每页一遍干跑记下全部字形原点与字符,再逐块比对;或只对点中的块判定可编辑。
### P-05 连续快画或快点被拒:“文档在此间已改动,未执行”
- 严重度:中 | 置信度:已确认
- 位置:`apps/ely-pdf/src/annotate.rs:189-203`(`settled_edit`)、`apps/ely-pdf/src/content.rs:209-214`、`apps/ely-pdf/src/writes.rs:176-190`、`apps/ely-pdf/src/edits.rs:56-93`
- 现象:上一笔还在落地(`annotate`+重读页数、页框、书签)时结束的下一笔、下一个矩形、下一个复选框点击被丢弃,并把原因说成“文档改动”;多笔手绘丢笔。
- 原因:`settled()` 是队列为空且不在编辑中;按坐标的批注不会被在途编辑作废(按编号的才会,worker 已以 `stale:` 拒绝),却被一律拒绝而不是排队。
- 修法:按坐标的批注进编辑队列;拒绝只留给按编号的编辑,并改正文案。
### P-06 默认字体“原字体”在 Type 1/Type 3 字体的文件上失败,只给引擎原话
- 严重度:中 | 置信度:可能(`pdf_font_writing_supported` 只认 TrueType/CFF/OpenType)
- 位置:`apps/ely-pdf/src/editing.rs:77`、`crates/ely-pdf-engine/shim/ely_write.c:61-67`、`apps/ely-pdf/src/writes.rs:115`、`:138-142`
- 现象:LaTeX 或旧 PDF 上“应用”报“previewing text failed: the document's font cannot write new text”,不提示换字体(缺字形的情形倒有 `pdf.text.missing` 提示)。
- 修法:像 `Wrote::Missing` 一样在面板里提示换字体,或读块时预判可写性、默认 Helvetica。
### P-07 含连字的文字块永远不可编辑
- 严重度:中 | 置信度:可能
- 位置:`crates/ely-pdf-engine/shim/ely_text.c:91-109`(`block_targets` 按 stext 字符计数)、`:112-130`(`take_glyph` 按字形计数)、`:167`
- 现象:含 fi、fl、ffi 连字的段落不出编辑框、点击无效;真放过去 `ely_remove_block` 也会报“N of M glyphs were found”。
- 修法:按同一原点的字符分组计字形,或每字形按 `ucslen` 多计命中。
### P-08 页面读取失败后每帧重试、每帧弹提示
- 严重度:中 | 置信度:已确认
- 位置:`apps/ely-pdf/src/annotate.rs:76-110`(`ask_notes`)、`:113-134`、`apps/ely-pdf/src/content.rs:129-156`(`ask_layers`)、`apps/ely-pdf/src/attach.rs:184-205`、`apps/ely-pdf/src/sign.rs:49-75`
- 现象:某页的批注、层、附件、名称或签名读一次失败后,`fail` 弹提示、`notify` 重绘、重绘再问、再失败,没完没了;worker 死掉后每个可见页每帧一条错误提示,直到按“重新打开”。
- 原因:失败不记页(图块有 `failed`/`thumbs_failed`,这些没有)。
- 修法:各类读取加按页的失败集合(`refresh()` 清),引擎已结束时不再问。
### P-09 识别文字的写入被拒后仍弹“成功”
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-pdf/src/ocr.rs:510-513`(`toast(.., Success)` 在 `on_same` 之后无条件执行)
- 修法:成功提示移进 `on_same` 的闭包。
### P-10 预览回复被丢弃时“应用”静默无事
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-pdf/src/writes.rs:72-86`、`apps/ely-pdf/src/view.rs:241-247`(`read` 丢弃编辑前的回复而不调 `then`)
- 修法:改用带版本核对的 `off`,不符即提示 `pdf.object.changed`。
### P-11 撤销或重做在 MuPDF 侧失败后 worker 的步数不再同步
- 严重度:低 | 置信度:已确认
- 位置:`crates/ely-pdf-engine/src/bin/ely-pdf-worker/main.rs:62-86`、`:161-165`
- 修法:失败时 `doc.sync(&self.mupdf, None)`。
### P-12 `unhook` 中途抛出时泄漏保留的对象
- 严重度:低 | 置信度:已确认
- 位置:`crates/ely-pdf-engine/shim/ely_insert.c:17-49`、`:352-356`(`n` 只在返回时发布,`fz_always` 以 0 释放)
- 修法:以 `int *n` 传入并逐个递增。
### P-13 优化导出的“之前”大小取磁盘文件而非当前文档
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-pdf/src/output.rs:217-224`
- 修法:量 worker 的快照,或标签有改动时不写“之前”。
### P-14 文字块含控制字符时 JSON 回复不可解析
- 严重度:低 | 置信度:可能
- 位置:`crates/ely-pdf-engine/shim/ely_text.c:240`(`%q` 不是 JSON 转义;别处用的是 `ely_json_string`)
- 现象:ToUnicode 坏掉的文件文字里有 U+0001–U+001F,`text_blocks` 解析失败,编辑工具永远读不到该页,并落入 P-08 的循环。
- 修法:`text`、`font` 用 `ely_json_string` 写。
## D ElyDoc(Writer)
### D-01 导出或打印对话框开着时关闭其标签即崩溃
- 严重度:高 | 置信度:已确认
- 位置:`apps/ely-doc/src/actions.rs:84-86`(`self.tab(*tab).expect("a modal keeps its tab")`),对话框由 `apps/ely-doc/src/output.rs:38-48` 打开;关闭路径 `crates/ely-office/src/close.rs:38-42`、`:142-154`、`:191-221`
- 现象:开着“导出…”或“打印…”对话框按 Cmd+W、点窗口关闭钮或 Cmd+Q,标签被移除后下一帧渲染 `expect` 失败,进程崩溃;Cmd+Q 时先于其余脏标签的保存询问崩溃,这些标签只剩 10 秒一次的恢复点。
- 原因:`Dialog::Output` 记着标签号,`dialog_element` 每帧无条件解包;没有任何地方在标签关闭时清 `self.dialog`(`Docs` 未实现 `Host::tab_closed`),`release_tab` 还把 `output()` 排队的 `when_settled` 续动作丢掉。其他对话框(`Ask`、`Link`、`Reference`、外壳的 `Unsaved`)都是防御性写法。
- 复现:打开任一文档 → 文件›导出… → Cmd+W。
- 修法:`dialog_element` 在 `tab()` 为 `None` 时清掉对话框并返回 `None`;`Docs` 实现 `tab_closed` 丢掉指向该标签的 `Dialog::Output`。
### D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录
- 严重度:高 | 置信度:已确认
- 位置:`apps/ely-doc/src/merge.rs:88-94`(`source()` 返回 `core.active`)、`:152-164`(预览)、`:178-195`(生成);`apps/ely-doc/src/merge/run.rs:351-371`(预览经 `load_into` 开新标签)、`crates/ely-office/src/open.rs:231`(新标签成为当前)
- 现象:选 CSV、插字段、点“预览”(填好的副本开成新标签并成为当前标签,合并面板仍在右侧)再点“生成”:模板成了预览副本,字段早已替换成第 N 条记录,N 个输出文件内容相同,文件名还带预览标签名;再点预览是预览的预览。无任何提示。
- 原因:`Merge` 不记来源标签,每次点击时取 `core.active`;单测 `a_merge_keeps_its_source_when_the_tab_changes` 直接传 `source`,没覆盖界面路径。
- 复现:任一含 `«Name»` 的文档 → 邮件合并 → 三行 CSV → 预览 → 生成。
- 修法:选 CSV 或插字段时记下来源标签号(或在 `Extra` 标记预览标签),面板显示来源名,预览与生成都用它,当前标签是预览副本时拒绝。
### D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名)
- 严重度:中 | 置信度:已确认
- 位置:`apps/ely-doc/src/insert.rs:318-324`(`insert_section`)、`apps/ely-doc/src/refs.rs:81-86`(`read`,书签、交叉引用、公式对话框用)、`apps/ely-doc/src/props.rs:116-122`(`read_fonts`)、`:133-137`(`read_styles`,每次切标签与 `Reported` 都跑)
- 现象:保存大文档、邮件合并几百条记录(每条载入、替换、另存、关闭都持锁)期间,切标签或打开“插入›书签…”整窗冻结到锁空出来为止。
- 原因:违反 2026-10-05 决策“界面线程不碰 worker”,这四处直接 `worker.lock()` 而不走 `read_in`/`read_off`。
- 修法:改为后台读取,回来时核对仍是同一标签与视图。
### D-04 一条批注的时间读不出,全部批注与修订都不显示
- 严重度:中 | 置信度:可能(内核对无日期批注写 `0-00-00`,`utl::toISO8601` 的空日期即如此)
- 位置:`apps/ely-doc/src/review.rs:164-167`、`:373-393`(`threads` 遇第一个解析失败即整体 `Err`)、`:179-181`
- 现象:打开含无日期批注的 DOCX(`w:comment` 无 `w:date`),批注与修订两栏都空,并弹引擎错误提示;每次审阅事件重读再失败再弹。
- 原因:单条解析失败被提升为整表失败,`and_then` 连已解析的 `changes` 一起丢掉。
- 修法:逐条解析时间,失败的那条用占位时间;`changes` 不因批注失败而丢。
### D-05 邮件合并某条记录失败时内核文档泄漏
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-doc/src/merge/run.rs:464-470`
- 原因:`fill`/`save_as` 以 `?` 提前返回,`close_doc` 不执行,`Doc` 无 `Drop`;每次重试多漏一份,worker 的 `N documents open` 计数漂移。
- 修法:先取结果再一律 `close_doc`,最后传出首个错误。
### D-06 导航栏大纲点击按当前列表取下标,后台重读后可能越界
- 严重度:低 | 置信度:可能
- 位置:`apps/ely-doc/src/navigate.rs:277-283`(`navigator.outline[at]`)
- 修法:用 `get(at)`,或在渲染时把目标矩形放进行的闭包。
### D-07 导航栏隐藏时仍随每次光标移动重读大纲与书签
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-doc/src/chrome.rs:236-245`(`left_view` 不看 `navigating` 就 `navigator.show(tab)`)、`apps/ely-doc/src/navigate.rs:79`、`:91-123`
- 现象:关掉导航栏后每次按键仍排两次内核读取,与键入争 worker 锁(规则 9:空闲不做事)。
- 修法:隐藏时 `show(None)`,显示时重读一次。
### D-08 Linux、Windows 上“打印…”可用,导出 PDF 后才报“此平台未构建”
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-doc/src/output.rs:28-30`、`:80-88`;`crates/ely-platform/src/print.rs:87-89`
- 现象:菜单与页码范围对话框都正常,最后一步弹出带临时路径的错误提示。各文档应用同(见 X-13)。
- 修法:平台未实现时禁用菜单项或在对话框里写明;错误文字去掉路径。
## M ElyMark(CEF)
### M-01 键入期间外部以相同内容重写文件,丢掉上次停顿之后键入的全部文字
- 严重度:高 | 置信度:已确认
- 位置:`apps/ely-mark/src/outside.rs:223-224`(`(true, _) | (false, false) => take_disk`)、`:243-268`(`take_disk`)、`apps/ely-mark/src/view.rs:184-196`(`refresh` 把 `changed`、`reported` 归零)、`apps/ely-mark/web/src/main.js:52-59`(全文停手 300 毫秒才送)、`:179-187`(`refresh` 清掉待送)
- 现象:连续键入时外部进程以相同字节重写文件(`touch`、同内容的 `git checkout`/`stash pop`、云同步重盖、格式化器输出相同),编辑器以旧文字重建,上次停顿以来键入的字全部消失,标签还显示已保存;无提示。
- 原因:页面只在停手 300 毫秒后送全文,键入中 Rust 侧 `doc.current` 是停顿前的文字;`disk.text == current` 的分支不看 `dirty`(它含 `edit_pending`),直接 `take_disk`。与 agents.md “有未保存内容(含页面还没送回的编辑)即不动磁盘与编辑器”相悖。
- 复现:打开 `a.md` 连续键入几秒,期间在终端 `touch a.md`。
- 修法:`disk.text == current` 而视图未落定时不 `take_disk`:等 `when_settled` 后重判,或当作 `Outside::Changed`;落定且相同时只换指纹不动编辑器。
### M-02 `refresh` 归零版本计数却不换代,在途的一键让标签一直“未落定”
- 严重度:中 | 置信度:可能
- 位置:`apps/ely-mark/src/view.rs:193`、`:264-272`(`Loaded` 路径有换代)、`apps/ely-mark/web/src/main.js:179-187`、`crates/ely-web-content/src/engine.rs:127-143`;受影响 `crates/ely-office/src/close.rs:96-99`、`save.rs:325-326`、`apps/ely-mark/src/compare.rs:381-382`
- 现象:干净标签被外部改动刷新后,Cmd+S、Cmd+W、Cmd+Q、比较与自动保存都静静等 `when_settled`,直到再键入一个字才一起执行。
- 原因:刷新前排队的一键在渲染进程里先产生 `changed = N+1`,再执行 `refresh` 丢掉待送全文;Rust 已归零又收到 `changed`,没有 `text` 跟上,`settled()` 为假;`refresh` 不像 `Loaded` 那样换代,旧回报没被丢。
- 修法:`refresh` 自己换一代(`self.load += 1` 传给 `ely.refresh(text, load)`)。
### M-03 干净标签接过磁盘文字后关闭仍问“是否保存”
- 严重度:中 | 置信度:已确认
- 位置:`apps/ely-mark/src/outside.rs:243-268`(`take_disk` 不更新 `tab.mark`)、`apps/ely-mark/src/view.rs:192`、`apps/ely-mark/src/engine.rs:133-136`、`crates/ely-office/src/close.rs:107-128`
- 现象:文件在别处被改、ElyMark 静默重载后,Cmd+W 对没动过的标签弹未保存询问;比较对话框选“用磁盘上的”后同。
- 原因:`request_close` 比较 `Engine::history`(磁盘文字散列)与 `tab.mark`(载入或上次保存时的散列),不等即问。
- 修法:`take_disk`(与合并采用后)同步 `tab.mark = mark(&disk.text)`。
### M-04 第二次打印或导出 PDF 悄悄取消第一次,没有文件也没有错误
- 严重度:中 | 置信度:已确认
- 位置:`apps/ely-mark/src/export.rs:93-97`、`:276`(打印与导出共用 `print-{id}.html`)、`:315-334`(`self.printing = Some(..)` 覆盖)
- 原因:`Desk.printing` 只有一个槽,赋新值即丢掉上一个的隐藏 `WebView` 与订阅,第一份的 `Event::Printed` 无人接。
- 修法:按作业编号保存多份,或一份未完时拒绝第二份并提示。
### M-05 点页面里的链接什么也不发生
- 严重度:中 | 置信度:已确认
- 位置:`apps/ely-mark/src/view.rs:274`(`Event::Leave(url) => log::info!(..)`)、`crates/ely-web-content/src/fence.rs:319-324`
- 现象:预览与可视模式里点 `http(s)`/`mailto` 链接:不跳转、不开浏览器、不提示。PRD 允许用户主动打开外部链接,agents.md 说“交给应用”,应用没处理。
- 修法:`Leave` 时 `http`、`https`、`mailto` 交系统打开,其他协议提示拒绝(同 ElyPDF 的链接策略)。
### M-06 `on_app_quit` 无条件删恢复点与崩溃锁,AppKit 直接终止时丢未保存内容
- 严重度:中 | 置信度:可能(依赖 GPUI 对非 Quit 动作的终止也跑 `on_app_quit`)
- 位置:`apps/ely-mark/src/desk.rs:244-248`、`apps/ely-mark/src/restore.rs:145-155`(`Keeping::close`)
- 现象:注销、关机或 Dock 的“退出”经 `terminate:` 结束进程,`proceed(After::Quit)` 的未保存流程不跑,退出观察者却删掉 `session.lock` 与 `recovery/`;下次启动读到“干净结束”,什么也不回来。外壳的 `Recovering::end` 有同样假设(见 X-01)。
- 修法:退出观察者只在无标签(或全部干净)时清理,否则留下锁与恢复点。
### M-07 崩溃恢复的未命名副本按“第一个显示的未命名标签”配对,内容可能落错标签
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-mark/src/restore.rs:195-201`、`apps/ely-mark/src/desk.rs:226-228`
- 修法:`Waiting` 条目记下为它新建的标签号,`restore_shown` 按号配对。
### M-08 页面重载后悬着的渲染回调永不完成,导出既不成功也不报错
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-mark/src/view.rs:264-272`(`Loaded` 不清 `renders`)、`:210-220`、`apps/ely-mark/src/export.rs:165-185`
- 修法:`Loaded`、`Failed` 时把 `renders` 全部以错误结束。
### M-09 导出的保存面板出错被当作取消
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-mark/src/export.rs:129-131`(`let Ok(Ok(Some(path))) = chosen.await else { return log::info!(..) }`)
- 修法:照 `Desk::open_picked` 分三种情形,错误走 `panel_failed`。
### M-10 关闭查找框只清当前标签的高亮
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-mark/src/finding.rs:116-124`(`close_find` 只对 `active_view` 发 `unfind`)、`:60-69`、`apps/ely-mark/web/src/main.js:132`、`:270-273`
- 修法:对每个标签的视图发 `unfind`。
### M-11 页面把搜索词写进应用日志
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-mark/web/src/main.js:246`(`console.warn` 带 `place.query`)、`crates/ely-web-content/src/handlers.rs:272-275`(控制台警告进 `log::warn!`)
- 原因:违反规则 4(日志不含正文)。
- 修法:只记行号与是否标题。
### M-12 浏览器创建完成前 `WebView` 已释放,浏览器泄漏
- 严重度:低 | 置信度:已确认
- 位置:`crates/ely-web-content/src/view.rs:582-587`(`Drop` 时还没有浏览器)、`crates/ely-web-content/src/handlers.rs:349-354`(`on_after_created` 无条件存入)、`crates/ely-web-content/src/engine.rs:165-170`
- 现象:开了标签在约 100 毫秒内关掉,渲染进程留到退出。
- 修法:`on_after_created` 发现 `surface.closed` 即 `close_browser(true)`。
### M-13 编辑器构建失败时页面空白且不上报
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-mark/web/src/main.js:117-139`(`show` 无 `try/catch`)、`apps/ely-mark/web/src/visual.js:106-109`
- 现象:Milkdown `create()` 或 `newTable` 抛出时没有 `shown` 也没有 `Failed`,`#document` 空着,`MarkView.shown` 永为假(跳转、外观等一直等)。
- 修法:`build` 包 `try/catch`,可视失败退回源码并 `tell({kind:'failed'})`。
### M-14 `render_pdf` 在界面线程内嵌图片并写文件
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-mark/src/export.rs:276-282`(渲染回调里 `inline`、`read_to_string`、`fs::write`);对照 `:172-176` 的 `write_output` 已在后台
- 修法:暂存步骤移到 `background_spawn`。
## G ElyTree(Git)
### G-01 打开“文件历史”或“逐行追溯”后,中间区不再响应任何别的选择
- 严重度:中 | 置信度:已确认
- 位置:`apps/ely-tree/src/view.rs:185-187`(`body` 先于 `match self.side` 返回 `look_pane`);入口 `apps/ely-tree/src/hist_pane.rs:43-66`、`apps/ely-tree/src/pane.rs:483`
- 现象:按下差异标题栏的“文件历史”或“逐行追溯”后,点改动列表里另一个文件、历史里另一个提交、分支或储藏,中间区都不变;只有历史面板的小叉能退出。
- 原因:`show()`(`pick.rs:302`)、`pick_commit`、`pick_place`、`set_side` 都不清 `self.look`,只有 `close_look` 与 blame 的 `show_commit` 清。
- 修法:任何新选择都清 `self.look`,或只在 `side == Changes` 且所选文件不变时画它。
### G-02 进行中操作读取失败被吞掉;`git am` 冲突无提示条无错误
- 严重度:中 | 置信度:已确认(`git am` 细节为可能)
- 位置:`apps/ely-tree/src/ops.rs:81-98`(`doing`)、`apps/ely-tree/src/moves.rs:74`、`apps/ely-tree/src/conflict_pane.rs:62`、`:106-109`
- 现象:`doing()` 出错时 `opened.doing = Some(Err(..))`,没有任何显示或日志;冲突文件列出来了,却没有“继续/跳过/中止”提示,冲突栏两侧退回泛称。
- 原因:`rebase-apply` 一律当变基读 `onto`,`git am` 的 `rebase-apply/` 没有 `onto`,整个读取 `NotFound`;`describe()` 失败(如 `MERGE_HEAD` 指向已被清理的对象)同样整体失败。违反规则 2。
- 复现:终端里 `git am` 停在冲突,在 ElyTree 打开该仓库。
- 修法:`Some(Err)` 以提示条显示 Git 原话并记日志;无 `onto` 的 `rebase-apply` 视为 `git am` 进行中。
### G-03 新建的空仓库在“仓库”页显示两段 Git 致命错误(引用日志、LFS)
- 严重度:中 | 置信度:可能
- 位置:`apps/ely-tree/src/recover.rs:50-69`(`reflog … HEAD --`)、`apps/ely-tree/src/structure.rs:137-138`(`git lfs ls-files`)、`apps/ely-tree/src/places.rs:40-50`、`apps/ely-tree/src/side_places.rs:261-264`、`:279-281`
- 现象:“新建仓库…”后仓库页出现 “fatal: bad revision 'HEAD'” 与 “Git can't resolve ref: HEAD” 两块红字;仓库没有问题,空列表才是正确状态。
- 原因:HEAD 未诞生时显式传 `HEAD` 的 `reflog show` 以 128 退出,`run_or_one` 不接受;`git lfs ls-files` 同样先解析 HEAD。
- 修法:`status.head.oid` 为空时两项直接给空列表。
### G-04 停止取回/拉取/推送失败的提示写成“克隆没有停下”
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-tree/src/network.rs:126`(`stop_remote` 复用 `tree.error.stop`)、`crates/ely-ui/src/i18n/tree.rs:53`
- 修法:加一条不限动词的文案。
### G-05 合并结果保存失败报成“Git 失败”,`tree.error.save` 是死文案
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-tree/src/work.rs:424`(`(_, Err(error)) => failed("tree.error.write", …)`)、`crates/ely-ui/src/i18n/tree_ops.rs:127`、`:255`(定义却无人用,规则 6)
- 修法:`written()` 加 `(Write::Save, Err)` 分支用 `tree.error.save`。
### G-06 首次提交后紧接着取消暂存,按过期的“已有提交”判定跑成 `git rm --cached`
- 严重度:低 | 置信度:可能(竞争窗口为一次状态重读)
- 位置:`apps/ely-tree/src/work.rs:352`(`born` 取自缓存的 `opened.status`)、`:426-427`(`read` 异步、`next_write` 同步)、`:85-93`、`:103-112`
- 现象:新仓库里暂存、提交,列表刷新前按“全部取消暂存”:第二条写操作仍按 `oid: None` 跑 `rm --cached -f -r -q -- .`,把刚提交的全部文件标为删除(可恢复,但吓人)。
- 修法:写操作执行前在后台 `rev-parse --verify -q HEAD` 判定,不用缓存状态。
### G-07 任一引用没有创建日期,整个分支页读不出
- 严重度:低 | 置信度:可能
- 位置:`apps/ely-tree/src/branches.rs:33`(`%(creatordate:unix)`)、`:106`(空串解析失败整体 `Err`)、`apps/ely-tree/src/side_refs.rs:227-235`
- 复现:`git tag blobtag HEAD:README.md`,打开分支页。
- 修法:日期可缺(显示“—”),或跳过非提交对象的引用。
### G-08 清理预览后文件被外部删除,对话框只给裸 OS 错误
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-tree/src/work.rs:270`(`symlink_metadata(&at)?`)、`:415-422`
- 现象:标题“Git did not do it”,正文 “No such file or directory (os error 2)”,没有路径,Git 并未运行。
- 修法:映射成带路径的“自预览后已变”文案。
### G-09 后台读取回来的对话框覆盖用户同时打开的对话框
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-tree/src/network.rs:186`(推送选远程)、`:237`(强制推送)、`apps/ely-tree/src/recovering.rs:116`(移动分支)
- 现象:按“推送”(无上游)后立刻按“储藏改动…”并开始键入,`git remote` 回来时储藏对话框连同输入被换掉。
- 修法:只在 `tree.ask` 仍为空(或仍是本次请求)时赋值。
### G-10 选中的分支、标签、位置是窗口级的,跨仓库标签串台
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-tree/src/tree.rs:107-108`(`picked_ref`、`picked_place` 在 `Tree` 上)、`apps/ely-tree/src/refs_pane.rs:25-33`、`apps/ely-tree/src/side_refs.rs:238-245`
- 现象:仓库 A 选中 `main`,切到仓库 B,B 的分支页显示它自己的 `main` 为已选并带动作按钮。
- 修法:移入 `Opened`,或 `activate()` 时清掉。
### G-11 已选边的冲突块再选另一边,提示“结果已手改”
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-tree/src/conflict.rs:219-224`
- 修法:给“该块已选定”单独文案,或支持改选。
### G-12 符号链接父目录下,当前打开的工作树被当成别的工作树而可“移除”
- 严重度:低 | 置信度:可能
- 位置:`apps/ely-tree/src/places_pane.rs:253-255`、`:265-271`、`apps/ely-tree/src/structure.rs:245-247`
- 原因:`here` 用 `git worktree list` 记录的原始路径比对已规范化的 `opened.repo.root`(macOS `/tmp` → `/private/tmp`)。
- 修法:比对前 `canonicalize`。
### G-13 非 macOS 平台的死入口
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-tree/src/running.rs:167-173`(停止远程命令非 Unix 报未构建)、`apps/ely-tree/src/tools.rs:109-115`(“在终端中打开”非 macOS 报未构建)、`crates/ely-ui/src/i18n/tree_places.rs:147`、`:281`(“在访达中显示”文案在 Linux、Windows 照样显示)
- 修法:按 `cfg` 禁用或隐藏,`reveal` 换平台中性文案。
### G-14 `repo.rs` 的孤立文档注释
- 严重度:低 | 置信度:已确认
- 位置:`apps/ely-tree/src/repo.rs:10`(“How many repositories the recent list keeps.” 的常量早已移到 `ely_files::recent`,注释挂到了 `struct Repo` 上)
- 修法:删掉。
## K LOK 桥(`crates/ely-lok` 的 worker、客户端、事件)
T-01、T-04、T-07 与 S-01 的根源都在这一层,这里只列此外的问题。
### K-01 非 macOS 的 worker 不运行 VCL 主循环,内核的回调与异步输入永远不送达
- 严重度:高 | 置信度:可能(代码已读;Linux、Windows 未运行)
- 位置:`crates/ely-lok/src/worker.rs:149-164`(非 macOS 的 `serve` 只是阻塞的 `read_frame` 循环)、`patches/libreoffice/0001-lok-macos-tile-painting.patch`(`CallbackFlushHandler::scheduleFlush` 非 MACOSX 分支仍 `Application::PostUserEvent`)
- 现象:Linux、Windows 上 worker 能答同步请求(载入、画图块、保存),但作废、光标、状态、对话框关闭等回调一条也推不出来,键盘与鼠标(内核经 `SfxLokHelper::post*EventAsync` 以用户事件派发)也到不了文档——编辑器是一张静止的图。树莓派是明确目标。
- 原因:用户事件只在 `Application::Yield`/`lo_runLoop` 里派发;只有 macOS 路径以 GCD 加 `run_loop` 跑循环。
- 修法:各平台都以 unipoll 的 `runLoop` 加轮询回调驱动请求队列,照 macOS 的做法。
### K-02 图块的代际在画完之后才取,画到一半的作废被盖掉
- 严重度:中 | 置信度:可能
- 位置:`crates/ely-lok/src/worker/input.rs:76-81`(`painted`:先 `paint`,再 `events::generation(id)`)、`crates/ely-lok/src/view.rs:466`(`tile.generation >= self.generation` 即接受)
- 现象:`paintTile` 期间内核发出作废(Writer 画时懒排版、Impress 空闲任务),返回的像素是作废前的,却带着新代际;视图接受并不再重画,旧内容留到下一次无关的作废。
- 修法:`paint()` 之前先读代际并盖到图块上。
### K-03 `rect_of` 宽容解析把读不出的载荷悄悄吞掉
- 严重度:低 | 置信度:可能
- 位置:`crates/ely-lok/src/events/parse.rs:25-34`,用于 `events.rs:212`、`:217-218`、`:221`、`:248`
- 现象:不是 “x, y, w, h…” 的载荷(如 Impress/Writer 表格多选时 GRAPHIC_SELECTION 的 JSON)要么被静默丢掉(选区框与手柄消失、不报 fault),要么凑出四个数字就被误读。与规则 2 相悖。
- 修法:严格解析开头的数字字段,读不出像 `found_of` 一样进 `faults`。
### K-04 `Sheets.current` 用 `as u64` 转有符号的 part
- 严重度:低 | 置信度:可能
- 位置:`crates/ely-lok/src/worker.rs:377`(`doc.part() as u64`、`count as u64`)、`crates/ely-lok/src/client/doc.rs:40`
- 现象:`getPart()` 返回 -1(没有视图外壳,关闭或重排期间)时 `current` 成了 2^64-1;今天的应用用 `.get` 不会 panic,但列表与标签页会错。
- 修法:`try_from`,为负即报错。
## X 共用外壳与平台(ely-office、ely-files、ely-ui、ely-platform、ely-ipc)
### X-01 设置窗口在前时 Cmd+Q 直接退出,跳过未保存询问并抹掉恢复点
- 严重度:致命 | 置信度:已确认
- 位置:`crates/ely-ui/src/run.rs:77`(应用级 `on_action(Quit → cx.quit())`)、`crates/ely-ui/src/commands.rs:170`(`secondary-q` 不带键位上下文)、`:150`(菜单项)、`crates/ely-ui/src/settings/window.rs`(设置窗口根上没有 `Quit` 处理)、`crates/ely-office/src/recover.rs:59-71`、`crates/ely-files/src/crash.rs:29-46`
- 现象:文档应用开着设置窗口(Cmd+,)时按 Cmd+Q 或选应用菜单“退出”,进程立即结束:未保存文档没有询问;`on_app_quit` 跑 `Keeping::end()` 把这次运行标为干净结束,下次启动 `Crash::check` 读到干净、`prune` 删光恢复点,内容也找不回。
- 原因:动作先沿聚焦窗口的树派发,没人处理才落到应用级监听;文档宿主只在自己窗口根上截 `Quit`(走 `Closing::proceed(After::Quit)`),设置窗口的 `Shell` 不截,于是直接 `cx.quit()`。
- 复现:ElyDoc 新建文档键入几字,Cmd+, 打开设置,Cmd+Q。
- 修法:全局 `Quit` 改为让每个文档窗口走自己的退出流程(遍历 `cx.windows()` 派发),或设置窗口把 `Quit` 转发给主窗口宿主;`Keeping::end` 只在全部标签干净时删恢复点。
### X-02 共用 `settings.json` 读不出时 Doc、Table、Show 根本起不来
- 严重度:高 | 置信度:已确认
- 位置:`crates/ely-office/src/lok.rs:135`(启动任务里 `set_author(&user::name()?)`)、`:82`(`restart` 同)、`crates/ely-platform/src/user.rs:7`(`Shared::load()?`)
- 现象:`<数据目录>/ElyOffice/settings.json` 不能解析、`version` 大于 1 或某字段类型不对时,三个 LOK 应用都以“the document engine did not start: …settings.json…”的致命对话框退出(码 1);引擎崩溃后的“重新打开文档”同样失败。agents.md 规定设置读不出“即用默认值、记下原因”,`settings::install` 也确实这样做,唯独这条路径把它变成致命错误。
- 复现:往 settings.json 写 `{"version": 2}`,启动 ElyDoc。
- 修法:`start`/`restart` 里设置读不出时退回 `user::account()` 并记日志(设置窗口已有 Banner 说明);或让 `user::name()` 用 `ely_ui::settings::current` 已读好的值。
### X-03 第二个实例启动即清掉运行中实例的工作目录与恢复点
- 严重度:高 | 置信度:机制已确认;频率为可能(Linux 桌面每个文件起一个进程;macOS `open -n` 或开发构建)
- 位置:`crates/ely-office/src/lib.rs:219-226`(`fresh_work` 无条件 `remove_dir_all`),调用 `crates/ely-office/src/lok.rs:128`、`apps/ely-pdf/src/viewer.rs:102`、`apps/ely-mark/src/desk.rs:162`;`crates/ely-office/src/recover.rs:59-71` 与 `crates/ely-files/src/crash.rs:40-46`(锁里 pid 活着=干净结束)
- 现象:实例 B 启动时 (a) 删掉 `<data>/work`——A 的每个 `tab.work`——此后 A 的每次保存都在消失的目录里写 `save.{format}` 失败(`files.rs:48-50`),状态变“保存失败”、自动保存停止;(b) `Crash::check` 见 A 的 pid 活着判为干净,`prune` 删掉 A 的全部恢复点与 `index.json`;(c) `Crash::open` 把 `session.lock` 改成 B 的 pid,A 正常退出时删掉 B 的锁,B 之后崩溃被当作干净结束。
- 复现:Linux 从文件管理器连开两个 .docx;macOS `open -n -a ElyDoc`;然后在第一个实例 Cmd+S。
- 修法:数据目录加单实例锁(flock),已有实例在时把文件交给它;或工作目录按进程分(`work/<pid>`)、恢复点按锁 pid 存活判定不清理。
### X-04 属性栏字号菜单的 `expect("sizes are numbers")` 是脆弱点,但当前不可达
- 严重度:低 | 置信度:已确认(上游源码核实)
- 位置:`crates/ely-office/src/panel.rs:220-228`、`:283-285`
- 说明:上游 `FontHeightPayload`(`sfx2/source/control/unoctitm.cxx:960-966`)只发 `=<数字>`(禁用时为 0),不发 `disabled`;混合选区经补丁 0003 的 JSON 走 `MIXED` 被过滤。所以这里不会 panic。保留为脆弱点:任何将来改动内核状态格式都会让整个属性栏崩溃,`points` 应改为可失败。
### X-05 恢复点写不成时每 10 秒弹一次提示
- 严重度:中 | 置信度:已确认
- 位置:`crates/ely-office/src/recover.rs:227-231`、`crates/ely-files/src/recovery.rs:105-116`
- 现象:快照超过恢复点容量上限(默认 64 MB,可设到 32 MB;带视频的 PPTX、大 XLSX),或磁盘满、数据目录只读,只要标签还脏,每个计时周期都弹“office.recovery.failed”。
- 原因:失败时 `slot.written` 不变、也没有像 `keeping.behind` 那样记住上次失败,下个周期 `due` 又含该标签。
- 修法:按槽记上次失败文字,变了或成功才再提示;超容量时写 `written = Some(edited)` 并解释一次。
### X-06 `session.lock` 空或损坏即永久关闭恢复功能
- 严重度:中 | 置信度:已确认
- 位置:`crates/ely-files/src/crash.rs:21-25`(非原子写)、`:29-50`(其他内容返回 `InvalidData`)、`crates/ely-office/src/recover.rs:59`、`:134-141`
- 现象:断电截断 `session.lock` 后,每次启动都显示“office.recovery.off: bad session lock”、不写恢复点;没有任何代码重写该文件,用户得手删。
- 修法:解析不出当作 `Marker::Crashed`(给出恢复点并重写锁),锁文件原子写。
### X-07 加密文档的恢复点每次写入都解密全部已有点,且持 worker 锁
- 严重度:中 | 置信度:可能
- 位置:`crates/ely-files/src/recovery.rs:120-121`、`:164-190`、`:193-199`、`:225-237`;调用 `crates/ely-office/src/recover.rs:201-214`(在 `read_in` 内)
- 现象:带密码的 PDF 每 10 秒写点时读并 AES-GCM 解密每个 `.enc`(至多 20 份×文档大小)只为列出;`RecoveryKey::load()`(钥匙串加 1 秒锁循环)也每次都跑;PDF 视图排队的编辑与读取随之停顿(树莓派上更久)。
- 修法:按文件名列点,恢复时才解密;密钥每次运行读一次。
### X-08 另存为在等待关闭或退出时被拒绝,关闭链悬空,ElyMark 的会话清单冻结
- 严重度:中 | 置信度:已确认
- 位置:`crates/ely-office/src/save.rs:219-246`(`identity` 出错、`office.error.format`、`office.error.taken`、`office.error.convert` 四处提前返回不调 `close_cancelled`),对照 `:191-194`、`:419-424` 有调;`apps/ely-mark/src/desk.rs:85-88`、`apps/ely-mark/src/restore.rs:282-285`、`:341-343`、`:362-366`
- 现象:退出或关窗途中对未命名标签答“保存”、选了不可保存的扩展名或别的标签正打开的文件:弹一条提示,退出静静停住;ElyMark 的 `keeping.leaving` 留在 `true`,`keep()` 不再写 `session.json`,到下次尝试退出前打开、关闭、改名的标签都不进清单,崩溃后恢复缺它们。
- 复现:ElyMark 新建未命名文档,Cmd+Q → 保存 → 选 `notes.txt`。
- 修法:`write_as` 的每个提前返回在 `then.is_some()` 时调 `close_cancelled`。
### X-09 保存期间落地的编辑可能让自动保存停摆
- 严重度:低 | 置信度:可能(与事件次序有关)
- 位置:`crates/ely-office/src/open.rs:241-251`(只在 `ModifiedStatus` 假→真时 `session.edit()`)、`crates/ely-office/src/save.rs:68-70`、`:131-132`、`crates/ely-files/src/session.rs`(票据按写入时的修订)
- 现象:`save_owned` 清掉内核 modified、随后导出期间键入的 `=true` 若在导出续动作调 `ely_files::save` 之前处理,`reported` 看到 `(true, true)` 不调 `edit()`,`finish_save` 置 `Saved`;`tab.dirty()` 为真而 `state != Dirty`,自动保存不再触发直到手动保存。
- 修法:导出开始时取票据,或每次 `Engine::edited` 递增都 `session.edit()`。
### X-10 别的标签的文件所在卷消失时,本标签的另存为失败并被标为保存失败
- 严重度:低 | 置信度:已确认
- 位置:`crates/ely-office/src/save.rs:32-41`(`holder` 对每个标签 `same_file(open, target)?`)、`:231-234`、`:429-450`(`report` → `save_failed()`)
- 修法:`holder` 里解析不出的别的标签路径视为“不是同一文件”并记日志;写入前的拒绝不调 `save_failed()`。
### X-11 worker 的 stderr 中继遇到第一行非 UTF-8 即永久停止
- 严重度:低 | 置信度:已确认
- 位置:`crates/ely-ipc/src/relay.rs:31-34`(`lines()` 出错即 `return`)
- 现象:LibreOffice 或 MuPDF 打出一行非 UTF-8(Linux 上 Latin-1 文件名)后,之后的结构化日志、崩溃栈与 `Relay::last_error` 全丢。
- 修法:`read_until(b'\n')` 加 `from_utf8_lossy`,出错继续。
### X-12 恢复路径在界面线程做文件 I/O
- 严重度:低 | 置信度:已确认
- 位置:`crates/ely-office/src/recover.rs:89-98`(`flush_index` 的暂存写加 `sync_all`,`keep_tab`/`forget_tab` 调)、`:287`、`:316`(`restore` 同步调钥匙串与拷贝)
- 现象:每次开关标签都在界面线程 fsync `index.json`;崩溃后首启在界面线程查钥匙串(最多等 1 秒锁)并逐个拷贝恢复点。慢介质(树莓派、SD 卡)上可见卡顿(规则 9)。
- 修法:移到后台执行器,`in_window` 续。
### X-13 非 macOS 构建里仍提供只会报“未构建”的入口
- 严重度:低 | 置信度:已确认
- 位置:`crates/ely-platform/src/print.rs:87-89`(打印,各文档应用“文件›打印…”与 Cmd+P 照常)、`crates/ely-platform/src/trust.rs:23`(PDF 签名对话框)、`crates/ely-platform/src/media.rs:52`(ElyShow 插入媒体)、`crates/ely-files/src/rename.rs:63`(改名)
- 修法:按 `cfg` 或能力标志禁用菜单项,或补齐平台实现。
### X-14 `agents.md` 引用的 `docs/engineering-risks.md` 不存在
- 严重度:低 | 置信度:已确认
- 位置:`agents.md`(三处引用“证据见 `docs/engineering-risks.md` R-01”等);`docs/` 下无此文件
- 修法:补回文件或改引用。
+2 -2
View File
@@ -2,8 +2,8 @@
随发行说明发布,每个版本一份。支持某格式不等于支持它的全部功能;下表只写已验证的内容、已知限制与保存行为。
版本:Ely Office 0.1.1(首版六个应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark)。日期 2026-10-09。
内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0041);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0;CEF 154.0.32。
版本:Ely Office 0.1.0(首版:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark)。日期 2026-10-08。
内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0038);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0;CEF 154.0.32。
验证机:macOS 27.0 arm64。Windows 与 Linux 未验证。本机没有 Microsoft Office:与 Office 的交叉检查未做(待做)。
## 往返验证
+2 -2
View File
@@ -4,7 +4,7 @@
## 自有代码
Ely Office 的自有代码以 MIT 发布,全文在仓库根的 `LICENSE`,并随每个应用包放在 `Resources/Licenses/LICENSE`。随包的引擎不随之改许可:ElyPDF 链接的 MuPDF 是 AGPL-3.0,ElyPDF 的发行包整体须按 AGPL 履行(对应源码见上游源码包);ElyDoc、ElyTable、ElyShow 的 LibreOffice 仍为 MPL-2.0 等;ElyTree 带的 Git 仍为 GPL-2.0。组件库 Ely-GPUI-Components 保留其 MIT/Apache-2.0。
Ely Office 的自有代码以 AGPL-3.0-or-later 发布,全文在仓库根的 `LICENSE`,并随每个应用包放在 `Resources/Licenses/LICENSE`。组件库 Ely-GPUI-Components 保留其 MIT/Apache-2.0。
## 每个发行版附带
@@ -12,7 +12,7 @@ Ely Office 的自有代码以 MIT 发布,全文在仓库根的 `LICENSE`,并
| --- | --- | --- |
| `ElyOffice-<版本>-source.tar.gz` | 自有源码、`Cargo.lock`、`rust-toolchain.toml`、ElyMark 网页的 `package-lock.json`、上游补丁(`patches/libreoffice`、`patches/mupdf`)、构建与取上游的脚本(各上游钉在提交,脚本里写明地址与散列)、测试、文档 | `packaging/macos/release.sh` |
| `ElyOffice-<版本>-upstream-source.tar.gz` | 实际编译的 MuPDF 源码(钉住的提交加我们的补丁,AGPL)与随包 Git 的源码(v2.56.0,GPLv2) | 同上;`tests/scripts/release-macos.sh` 核对其中是打过补丁的 MuPDF |
| 各应用包 `Resources/Licenses/` | Ely 的 MIT 全文;`rust-crates.txt`(链进该应用各二进制的全部 crate 与其许可文件,无许可文件的取标准文本并写明作者);所带引擎的许可与通知(见下) | `packaging/licenses.sh`,缺任何一份即打包失败 |
| 各应用包 `Resources/Licenses/` | Ely 的 AGPL 全文;`rust-crates.txt`(链进该应用各二进制的全部 crate 与其许可文件,无许可文件的取标准文本并写明作者);所带引擎的许可与通知(见下) | `packaging/licenses.sh`,缺任何一份即打包失败 |
## 第三方,按应用
@@ -1,67 +0,0 @@
LOK Calc: an AutoFilter range keeps its rows
Calc regrows an unnamed AutoFilter range from the data around it: each
time the filter list opens (ExtendDataArea, ExtendBackColorArea, so
appended rows join) and whenever a command finds it from a single cell
(ScDocShell::GetDBData, so applying a condition, toggling it off, and
its undo). A totals row right under a marked block joined the filter,
showed in its list and hid with the data; a saved range reopened grew
the same way. Under LOK the range keeps the rows it was set on, as in
Excel, while the cursor is inside it; a cell in another block finds
that block. A single cell still takes the data area when the filter is set.
diff --git a/sc/source/core/tool/dbdata.cxx b/sc/source/core/tool/dbdata.cxx
index b1d714c55..9a01847ee 100644
--- a/sc/source/core/tool/dbdata.cxx
+++ b/sc/source/core/tool/dbdata.cxx
@@ -19,6 +19,7 @@
#include <sal/config.h>
#include <sal/log.hxx>
+#include <comphelper/lok.hxx>
#include <o3tl/safeint.hxx>
#include <unotools/transliterationwrapper.hxx>
@@ -646,6 +647,9 @@ bool ScDBData::UpdateReference(const ScDocument& rDoc, UpdateRefMode eUpdateRefM
void ScDBData::ExtendDataArea(const ScDocument& rDoc)
{
+ // LOK: an AutoFilter range keeps its rows, as in Excel.
+ if (comphelper::LibreOfficeKit::isActive() && bAutoFilter)
+ return;
// Extend the DB area to include data rows immediately below.
SCCOL nOldCol1 = nStartCol, nOldCol2 = nEndCol;
SCROW nOldEndRow = nEndRow;
@@ -665,6 +669,8 @@ void ScDBData::ExtendDataArea(const ScDocument& rDoc)
void ScDBData::ExtendBackColorArea(const ScDocument& rDoc)
{
+ if (comphelper::LibreOfficeKit::isActive() && bAutoFilter)
+ return;
// Extend the DB area to include data rows immediately below.
SCCOL nOldCol1 = nStartCol, nOldCol2 = nEndCol;
SCROW nOldEndRow = nEndRow;
diff --git a/sc/source/ui/docshell/docsh5.cxx b/sc/source/ui/docshell/docsh5.cxx
index d154597c3..5099dbe6f 100644
--- a/sc/source/ui/docshell/docsh5.cxx
+++ b/sc/source/ui/docshell/docsh5.cxx
@@ -19,6 +19,7 @@
#include <memory>
#include <sal/config.h>
+#include <comphelper/lok.hxx>
#include <cassert>
@@ -147,7 +148,10 @@ ScDBData* ScDocShell::GetDBData( const ScRange& rMarked, ScGetDBMode eMode, ScGe
if (!bSelected)
{
bUseThis = true;
- if ( bIsNoName && (eMode == SC_DB_MAKE || eMode == SC_DB_AUTOFILTER) )
+ // LOK: an AutoFilter range keeps its rows, as in Excel.
+ const bool bKeep = comphelper::LibreOfficeKit::isActive() && pData->HasAutoFilter()
+ && nCol >= nOldCol1 && nCol <= nOldCol2 && nRow >= nOldRow1 && nRow <= nOldRow2;
+ if ( bIsNoName && !bKeep && (eMode == SC_DB_MAKE || eMode == SC_DB_AUTOFILTER) )
{
// If nothing marked or only one row marked, adapt
// "unnamed" to contiguous area.
@@ -1,139 +0,0 @@
LOK Calc: a new chart lands beside its data
FuInsertChart placed a chart away from aPositionRange, which for
request arguments (a kit client gives the range in FN_PARAM_5) was the
cursor cell alone; GetChartInsertPos then pulled the chart back into
the visible area. The chart covered the data it charts, or a block
next to it. Under LOK, with a requested range, the chart goes in the
first empty, unmerged cells of its own sheet right of its data large
enough for it, else below, left or above, else the first such block
anywhere, whatever the visible area. Data on another sheet starts the
search at the cursor. With no room on the sheet the insert fails and
nothing lands. Sizes are summed from each candidate cell.
diff --git a/sc/source/ui/drawfunc/fuins2.cxx b/sc/source/ui/drawfunc/fuins2.cxx
index b0169d0..121500d 100644
--- a/sc/source/ui/drawfunc/fuins2.cxx
+++ b/sc/source/ui/drawfunc/fuins2.cxx
@@ -47,6 +47,8 @@
#include <vcl/weldutils.hxx>
#include <comphelper/lok.hxx>
+#include <o3tl/unit_conversion.hxx>
+#include <optional>
#include <comphelper/storagehelper.hxx>
#include <comphelper/propertysequence.hxx>
#include <com/sun/star/embed/EmbedVerbs.hpp>
@@ -215,6 +217,69 @@ void lcl_ChartInit(const uno::Reference <embed::XEmbeddedObject>& xObj, ScViewDa
// don't create chart listener here (range may be modified in chart dialog)
}
+
+/// The last column from nCol spanning nWidth twips, if any.
+std::optional<SCCOL> lcl_Across(const ScDocument& rDoc, SCTAB nTab, SCCOL nCol, tools::Long nWidth)
+{
+ tools::Long nSum = 0;
+ for (SCCOL n = nCol; n <= rDoc.MaxCol(); ++n)
+ if ((nSum += rDoc.GetColWidth(n, nTab)) >= nWidth)
+ return n;
+ return std::nullopt;
+}
+
+/// The last row from nRow spanning nHeight twips, if any.
+std::optional<SCROW> lcl_Down(const ScDocument& rDoc, SCTAB nTab, SCROW nRow, tools::Long nHeight)
+{
+ tools::Long nSum = 0;
+ for (SCROW n = nRow; n <= rDoc.MaxRow(); ++n)
+ if ((nSum += rDoc.GetRowHeight(n, nTab)) >= nHeight)
+ return n;
+ return std::nullopt;
+}
+
+/// LOK: empty cells right of rData, else below, left, above.
+std::optional<Point> lcl_EmptyBeside(const ScDocument& rDoc, const ScRange& rData, const Size& rSize)
+{
+ const SCTAB nTab = rData.aStart.Tab();
+ const tools::Long nWidth = o3tl::convert(rSize.Width(), o3tl::Length::mm100, o3tl::Length::twip);
+ const tools::Long nHeight = o3tl::convert(rSize.Height(), o3tl::Length::mm100, o3tl::Length::twip);
+ // Merged cells take their whole area, anchor or not.
+ const auto free = [&](SCCOL nCol1, SCROW nRow1, SCCOL nCol2, SCROW nRow2) {
+ return rDoc.IsBlockEmpty(nCol1, nRow1, nCol2, nRow2, nTab)
+ && !rDoc.HasAttrib(nCol1, nRow1, nTab, nCol2, nRow2, nTab, HasAttrFlags::Merged | HasAttrFlags::Overlapped);
+ };
+ const auto at = [&](SCCOL nCol, SCROW nRow) { return rDoc.GetMMRect(nCol, nRow, nCol, nRow, nTab).TopLeft(); };
+ const SCROW nTop = rData.aStart.Row();
+ const SCCOL nLeft = rData.aStart.Col();
+ // Sideways scans share rows; vertical scans share columns.
+ if (const auto oBottom = lcl_Down(rDoc, nTab, nTop, nHeight))
+ {
+ for (SCCOL nCol = rData.aEnd.Col() + 2; nCol <= rDoc.MaxCol(); ++nCol)
+ if (const auto oRight = lcl_Across(rDoc, nTab, nCol, nWidth); oRight && free(nCol, nTop, *oRight, *oBottom))
+ return at(nCol, nTop);
+ }
+ const auto oRight = lcl_Across(rDoc, nTab, nLeft, nWidth);
+ for (SCROW nRow = rData.aEnd.Row() + 2; oRight && nRow <= rDoc.MaxRow(); ++nRow)
+ if (const auto oBottom = lcl_Down(rDoc, nTab, nRow, nHeight); oBottom && free(nLeft, nRow, *oRight, *oBottom))
+ return at(nLeft, nRow);
+ if (const auto oBottom = lcl_Down(rDoc, nTab, nTop, nHeight))
+ {
+ for (SCCOL nCol = nLeft - 2; nCol >= 0; --nCol)
+ if (const auto oEnd = lcl_Across(rDoc, nTab, nCol, nWidth); oEnd && *oEnd < nLeft - 1 && free(nCol, nTop, *oEnd, *oBottom))
+ return at(nCol, nTop);
+ }
+ for (SCROW nRow = nTop - 2; oRight && nRow >= 0; --nRow)
+ if (const auto oEnd = lcl_Down(rDoc, nTab, nRow, nHeight); oEnd && *oEnd < nTop - 1 && free(nLeft, nRow, *oRight, *oEnd))
+ return at(nLeft, nRow);
+ // Last, any chart-sized empty block, row bands first.
+ for (SCROW nRow = 0; const auto oBottom = lcl_Down(rDoc, nTab, nRow, nHeight); nRow = *oBottom + 1)
+ for (SCCOL nCol = 0; const auto oEnd = lcl_Across(rDoc, nTab, nCol, nWidth); nCol = *oEnd + 1)
+ if (free(nCol, nRow, *oEnd, *oBottom))
+ return at(nCol, nRow);
+ return std::nullopt;
+}
+
}
FuInsertOLE::FuInsertOLE(ScTabViewShell& rViewSh, vcl::Window* pWin, ScDrawView* pViewP,
@@ -452,6 +517,14 @@ FuInsertChart::FuInsertChart(ScTabViewShell& rViewSh, vcl::Window* pWin, ScDrawV
aRangeString = static_cast<const SfxStringItem*>(pItem)->GetValue();
aPositionRange = rViewSh.GetViewData().GetCurPos();
+ // The chart goes beside its data, not over it.
+ if (pRange)
+ {
+ const ScRangeList aData = *sctools::chartRanges(rViewSh.GetViewData().GetDocument(), pRange->GetValue());
+ aPositionRange = aData[0];
+ for (size_t i = 1; i < aData.size(); ++i)
+ aPositionRange.ExtendTo(aData[i]);
+ }
}
else
{
@@ -612,7 +685,26 @@ FuInsertChart::FuInsertChart(ScTabViewShell& rViewSh, vcl::Window* pWin, ScDrawV
// object position
// get chart position (from window size and data range)
- Point aStart = rViewSh.GetChartInsertPos( aSize, aPositionRange );
+ // LOK: beside the data, never over it, view or not.
+ std::optional<Point> oBeside;
+ if (pRange && comphelper::LibreOfficeKit::isActive())
+ {
+ // The chart's own sheet; data elsewhere starts at the cursor.
+ const SCTAB nTab = rData.CurrentTabForData();
+ ScRange aNear = aPositionRange.aStart.Tab() == nTab ? aPositionRange : ScRange(rData.GetCurPos());
+ aNear.aStart.SetTab(nTab);
+ aNear.aEnd.SetTab(nTab);
+ oBeside = lcl_EmptyBeside(rData.GetDocument(), aNear, aSize);
+ if (!oBeside)
+ {
+ SAL_WARN("sc.lok", "no empty cells hold the chart");
+ if (xChartModel.is())
+ xChartModel->unlockControllers();
+ rViewSh.GetObjectShell()->GetEmbeddedObjectContainer().RemoveEmbeddedObject(aName);
+ return;
+ }
+ }
+ Point aStart = oBeside ? *oBeside : rViewSh.GetChartInsertPos( aSize, aPositionRange );
tools::Rectangle aRect (aStart, aSize);
rtl::Reference<SdrOle2Obj> pObj = new SdrOle2Obj(
-2
View File
@@ -83,5 +83,3 @@ done
echo "build-libreoffice.sh: dictionaries kept: $(ls "$extensions" | tr '\n' ' ')"
# Ely's kernel defaults: Excel A1 formulas.
cp "$root/packaging/libreoffice/ely.xcd" "$src/instdir/LibreOffice.app/Contents/Resources/registry/"
"$root/scripts/inert-lo.sh" "$src/instdir/LibreOffice.app"
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
# Unpacks the official kernel's libraries, never mounting the image.
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
dmg=$root/engines/LibreOffice_26.2.6_MacOS_aarch64.dmg
out=$root/engines/lo-official
command -v 7zz >/dev/null || { echo "extract-official-lo.sh: needs 7zz (brew install sevenzip)" >&2; exit 1; }
[ -f "$dmg" ] || { echo "extract-official-lo.sh: no $dmg" >&2; exit 1; }
[ "$(stat -f %z "$dmg")" = 297798926 ] || { echo "extract-official-lo.sh: $dmg is not the pinned image" >&2; exit 1; }
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
7zz x -y -snld -o"$work" "$dmg" "LibreOffice/LibreOffice.app/Contents/Frameworks/*" "LibreOffice/LibreOffice.app/Contents/Resources/*" >/dev/null
rm -rf "$out"
mkdir -p "$out"
# A plain folder, not a bundle, so nothing registers.
mv "$work/LibreOffice/LibreOffice.app/Contents" "$out/Contents"
echo "extract-official-lo.sh: $out/Contents/Frameworks"
+1 -1
View File
@@ -33,7 +33,7 @@ fetch() { # name url rev [sparse paths...]
echo "fetch $name $rev"
}
fetch Ely-GPUI-Components https://github.com/ZacharyZhang-NY/Ely-GPUI-Components.git b0dbfd429c17819f6c143fd365852244d497514a
fetch Ely-GPUI-Components https://github.com/ZacharyZhang-NY/Ely-GPUI-Components.git 69265b4fd4da888e2d8e1268021af4d358e67afb
fetch zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b
fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \
include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
# The built LibreOffice bundle holds libraries, nothing to register.
set -euo pipefail
app=${1:?usage: scripts/inert-lo.sh path/to/LibreOffice.app}
plist=$app/Contents/Info.plist
plutil -lint -s "$plist" || { echo "inert-lo.sh: $plist is missing or unreadable" >&2; exit 1; }
lsregister=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister
for key in CFBundleDocumentTypes CFBundleURLTypes UTExportedTypeDeclarations UTImportedTypeDeclarations; do
if /usr/libexec/PlistBuddy -c "Print :$key" "$plist" >/dev/null 2>&1; then
/usr/libexec/PlistBuddy -c "Delete :$key" "$plist"
fi
done
rm -rf "$app/Contents/PlugIns" "$app/Contents/Library/Spotlight"
# -10814: not registered, which is the goal.
if ! said=$("$lsregister" -u "$app" 2>&1); then
grep -q -- "-10814" <<<"$said" || { echo "inert-lo.sh: $said" >&2; exit 1; }
fi
# Spotlight would find the bundle and register it again.
touch "$(cd "$app/../../.." && pwd)/.metadata_never_index"
echo "inert-lo.sh: $app claims no files and is unregistered"
+22 -37
View File
@@ -61,44 +61,29 @@ test("the lamp turns the page dark in a wave", async ({ page }) => {
await expect(page.locator("html")).toHaveCSS("background-color", "rgb(37, 37, 37)");
});
for (const scheme of ["light", "dark"] as const) {
test(`${scheme} text and links keep 4.5:1 against the page`, async ({ page }) => {
await page.emulateMedia({ colorScheme: scheme });
await open(page);
const worst = await page.evaluate(() => {
const parse = (c: string) => {
const [r, g, b, a = 1] = c.match(/[\d.]+/g)!.map(Number);
return { rgb: [r, g, b], a };
};
const lum = (rgb: number[]) => {
const [r, g, b] = rgb.map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ground = parse(getComputedStyle(document.documentElement).backgroundColor).rgb;
const page = lum(ground);
const alpha = (el: HTMLElement) => {
let a = parse(getComputedStyle(el).color).a;
for (let at: Element | null = el; at; at = at.parentElement) a *= Number(getComputedStyle(at).opacity);
return a;
};
const shown = (el: HTMLElement) => {
const a = alpha(el);
return parse(getComputedStyle(el).color).rgb.map((v, i) => v * a + ground[i] * (1 - a));
};
const ratios = [...document.querySelectorAll<HTMLElement>("h1, h2, h3, p, a, li, button")]
.filter((el) => el.textContent?.trim() && alpha(el) > 0)
.map((el) => {
const text = lum(shown(el));
return { ratio: (Math.max(text, page) + 0.05) / (Math.min(text, page) + 0.05), text: el.textContent };
});
return ratios.sort((a, b) => a.ratio - b.ratio)[0];
});
expect(worst.ratio, String(worst.text)).toBeGreaterThanOrEqual(4.5);
test("dark text keeps 4.5:1 against the page", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" });
await open(page);
const worst = await page.evaluate(() => {
const parse = (c: string) => c.match(/[\d.]+/g)!.slice(0, 3).map(Number);
const lum = (c: string) => {
const [r, g, b] = parse(c).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const page = lum(getComputedStyle(document.documentElement).backgroundColor);
const ratios = [...document.querySelectorAll<HTMLElement>("h1, h2, h3, p, a, li, button")]
.filter((el) => el.textContent?.trim() && getComputedStyle(el).color !== "rgba(0, 0, 0, 0)")
.map((el) => {
const text = lum(getComputedStyle(el).color);
return { ratio: (Math.max(text, page) + 0.05) / (Math.min(text, page) + 0.05), text: el.textContent };
});
return ratios.sort((a, b) => a.ratio - b.ratio)[0];
});
expect(worst.ratio, String(worst.text)).toBeGreaterThanOrEqual(4.5);
});
}
test("the system scheme sets the first theme; the lamp's choice stays", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" });
+11 -27
View File
@@ -3,7 +3,7 @@ import { TEXT, type Key, type Lang } from "../src/text.ts";
const REPO = "https://github.com/ZacharyZhang-NY/ElyOffice";
const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
const RELEASES = `${REPO}/releases/latest`;
const dmg = (app: string) => `${REPO}/releases/download/v0.1.0/${app}-0.1.0-arm64.dmg`;
test.use({ reducedMotion: "reduce" });
@@ -78,25 +78,26 @@ test.describe("English locale", () => {
await expect(lang).toHaveText("中文");
});
test("downloads go to the GitHub release page, never to a file", async ({ page }) => {
test("links point at the release and the repository", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".tour .get")).toHaveCount(APPS.length);
for (const i of APPS.keys()) await expect(page.locator(".tour .get").nth(i)).toHaveAttribute("href", RELEASES);
await expect(page.locator("#download a")).toHaveCount(1);
await expect(page.locator("#download [data-t=github]")).toHaveAttribute("href", RELEASES);
for (const [i, app] of APPS.entries()) {
await expect(page.locator(".tour .get").nth(i)).toHaveAttribute("href", dmg(app));
}
await expect(page.locator("#download ul a")).toHaveText([...APPS, TEXT.en.source]);
for (const [i, app] of APPS.entries()) {
await expect(page.locator("#download ul a").nth(i)).toHaveAttribute("href", dmg(app));
}
await expect(page.locator("[data-t=source]")).toHaveAttribute("href", REPO);
await expect(page.locator(".hero > a")).toHaveText("GitHub");
await expect(page.locator(".hero > a")).toHaveAttribute("href", REPO);
const files = await page.locator("a[href]").evaluateAll((as) =>
as.map((a) => (a as HTMLAnchorElement).href).filter((h) => /\.(dmg|tar\.gz|zip|exe|AppImage)$/.test(h)),
);
expect(files).toEqual([]);
});
test("every external link answers @net", async ({ page, request }) => {
await page.goto("/");
const hrefs = await page.locator("a[href^='http']").evaluateAll((as) => as.map((a) => (a as HTMLAnchorElement).href));
const unique = [...new Set(hrefs)];
expect(unique.sort()).toEqual([REPO, RELEASES].sort());
expect(unique.sort()).toEqual([REPO, ...APPS.map(dmg)].sort());
for (const url of unique) {
const res = await request.head(url);
expect(res.status(), url).toBeLessThan(400);
@@ -123,20 +124,3 @@ test.describe("English locale", () => {
}
});
});
test("the download area tells how to open an unnotarized app", async ({ browser }) => {
for (const [locale, words] of [
["en-US", ["System Settings", "Privacy & Security", "Open Anyway"]],
["zh-CN", ["系统设置", "隐私与安全性", "仍要打开"]],
] as const) {
const page = await browser.newPage({ locale, reducedMotion: "reduce" });
await page.goto("/");
const steps = page.locator("#download [data-t=unsigned]");
await expect(steps).toBeVisible();
for (const word of words) await expect(steps).toContainText(word);
await page.locator(".lang").click();
await expect(steps).toBeVisible();
await expect(steps).not.toContainText(words[0]);
await page.close();
}
});
-48
View File
@@ -46,9 +46,6 @@ let errors: string[] = [];
test.beforeEach(async ({ page }) => {
errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
page.on("requestfailed", (r) => errors.push(`${r.url()} ${r.failure()?.errorText}`));
page.on("response", (r) => r.status() >= 400 && errors.push(`${r.url()} ${r.status()}`));
await page.goto("/");
await expect(page.locator(".model .hit-face:not(.ground)").first()).toBeAttached();
});
@@ -144,48 +141,3 @@ test("holding Enter sinks the focused building until release", async ({ page })
await page.keyboard.up("Enter");
await expect(page.locator(".block.pressed")).toHaveCount(0);
});
/** Pixels from one cell above the drawn building. */
const drift = (page: Page, name: string) =>
page.evaluate(([n, unit]) => {
const svg = document.querySelector<SVGSVGElement>(".tour .stage")!;
const cell = unit * svg.getScreenCTM()!.d;
const rects = [...document.querySelectorAll(`.block[data-part="${n}"] .face`)].map((f) => f.getBoundingClientRect());
const centre = (Math.min(...rects.map((r) => r.left)) + Math.max(...rects.map((r) => r.right))) / 2;
const top = Math.min(...rects.map((r) => r.top));
const box = document.querySelector<HTMLElement>(".tour .caption.shown")!.getBoundingClientRect();
return Math.max(Math.abs(box.left + box.width / 2 - centre), Math.abs(box.bottom - (top - cell)));
}, [name, CELL] as const);
test("a focused name follows the model when the window narrows", async ({ page }) => {
await page.setViewportSize({ width: 760, height: 1000 });
await page.locator(".doors a").nth(3).focus();
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBe(0);
await expect.poll(() => drift(page, "ElyPDF")).toBeLessThan(1);
});
for (const side of ["width", "height"] as const) {
test(`a focused name follows the model when only its ${side} changes`, async ({ page }) => {
await page.locator(".doors a").nth(3).focus();
await page.evaluate((side) => {
const svg = document.querySelector<SVGSVGElement>(".tour .stage")!;
// CSS size: the screen box includes the camera zoom.
svg.style[side] = `${parseFloat(getComputedStyle(svg)[side]) * 0.8}px`;
}, side);
await expect.poll(() => drift(page, "ElyPDF")).toBeLessThan(1);
});
}
test.describe("with motion", () => {
test.use({ reducedMotion: "no-preference" });
test("the name sits one cell above the building once it has lifted", async ({ page }) => {
await page.waitForFunction(() => document.getAnimations().every((a) => a.playState !== "running"), null, {
timeout: 20_000,
});
await page.locator(".doors a").first().focus();
await page.waitForTimeout(400);
expect(await drift(page, "ElyDoc")).toBeLessThan(1);
});
});
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+16
View File
@@ -110,3 +110,19 @@ export function stage(blocks: SVGGElement[]): SVGSVGElement {
svg.setAttribute("class", "stage");
return svg;
}
/** Pins an image across a block's left face. */
export function screen(g: SVGGElement, href: string, width: number, height: number): void {
const b = boxes.get(g);
if (!b) throw new Error("not a block");
const [e, f] = project(b.x, b.y + b.d, b.z + b.h);
const sx = (b.w * UNIT) / width;
const sy = (b.h * UNIT) / height;
const image = document.createElementNS(SVG, "image");
image.setAttribute("href", href);
image.setAttribute("width", String(width));
image.setAttribute("height", String(height));
image.setAttribute("preserveAspectRatio", "none");
image.setAttribute("transform", `matrix(${COS * sx} ${SIN * sx} 0 ${sy} ${e} ${f})`);
g.append(image);
}
+19 -5
View File
@@ -7,10 +7,12 @@ import { night } from "./night.ts";
import { rhythm } from "./rhythm.ts";
import { playground } from "./play.ts";
import { film } from "./film.ts";
import { model } from "./model.ts";
import { said, speak, type Lang } from "./text.ts";
import { RELEASES, VERSION } from "./release.ts";
const REPO = "https://github.com/ZacharyZhang-NY/ElyOffice";
const RELEASE = `${REPO}/releases/download/v0.1.0`;
const APPS = model.slice(1, -1).map((p) => p.name);
const app = document.querySelector<HTMLElement>("#app");
if (!app) throw new Error("index.html has no #app");
@@ -30,10 +32,22 @@ const shelf = props();
const footer = document.createElement("footer");
footer.id = "download";
const version = document.createElement("h2");
version.textContent = `Ely Office ${VERSION}`;
const get = said("a", "github");
get.href = RELEASES;
footer.append(version, said("p", "platforms"), said("p", "needs"), said("p", "unsigned"), get);
version.textContent = "Ely Office 0.1.0";
const list = document.createElement("ul");
for (const name of APPS) {
const a = document.createElement("a");
a.href = `${RELEASE}/${name}-0.1.0-arm64.dmg`;
a.textContent = name;
const li = document.createElement("li");
li.append(a);
list.append(li);
}
const source = said("a", "source");
source.href = REPO;
const li = document.createElement("li");
li.append(source);
list.append(li);
footer.append(version, said("p", "platforms"), said("p", "needs"), said("p", "unsigned"), list);
const toggle = said("button", "other");
toggle.className = "lang";
-3
View File
@@ -1,3 +0,0 @@
// The published release; downloads live on its GitHub page.
export const VERSION = "0.1.1";
export const RELEASES = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/latest";
+11 -6
View File
@@ -10,13 +10,14 @@ const en = {
ElyTree: "Stage lines, commit, read the graph. Git without a terminal.",
ElyMark: "Markdown as you read it. Math and diagrams in place.",
offline: "Works with the network off.",
open: "Open source, under the MIT licence.",
open: "Open source, under the AGPL.",
local: "Your files stay in your folders.",
download: "Download",
platforms: "Out now for macOS. Windows and Linux are coming soon.",
needs: "macOS 14 or later, Apple silicon.",
unsigned: "Not notarized. If macOS blocks the app, open System Settings › Privacy & Security and click Open Anyway.",
github: "Download from GitHub",
unsigned: "Not notarized. The first time, Control-click the app and choose Open.",
source: "Source code",
shot: "screenshot",
other: "中文",
roads: "Drag a file down the road.",
fileDoc: "ElyDoc file",
@@ -46,13 +47,14 @@ const zh: typeof en = {
ElyTree: "按行暂存、提交、看提交图。不用终端的 Git。",
ElyMark: "写的就是看到的 Markdown。公式与图表就地显示。",
offline: "断网照常工作。",
open: "开源,MIT 许可。",
open: "开源,AGPL 许可。",
local: "文件留在你自己的文件夹里。",
download: "下载",
platforms: "macOS 版已发布。Windows 与 Linux 版即将推出。",
needs: "macOS 14 或更新,Apple 芯片。",
unsigned: "未经公证。macOS 拦下应用时,打开“系统设置 › 隐私与安全性”,点“仍要打开”。",
github: "前往 GitHub 下载",
unsigned: "未经公证。第一次打开时,按住 Control 点按应用,选“打开”。",
source: "源代码",
shot: "截图",
other: "English",
roads: "把文件沿着路拖过去。",
fileDoc: "ElyDoc 文件",
@@ -89,4 +91,7 @@ export function speak(lang: Lang): void {
for (const el of document.querySelectorAll<HTMLElement>("[data-t]")) {
el.textContent = TEXT[lang][el.dataset.t as Key];
}
for (const el of document.querySelectorAll<SVGElement>("[data-shot]")) {
el.setAttribute("aria-label", `${el.dataset.shot} ${TEXT[lang].shot}`);
}
}
+6 -18
View File
@@ -23,8 +23,6 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
caption.setAttribute("aria-hidden", "true");
host.append(caption);
const follow = () => place(caption, host, svg, hit);
const sized = new ResizeObserver(follow);
const show = () => {
const up = state.hover || state.focus;
for (const b of blocks) {
@@ -32,15 +30,7 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
b.classList.toggle("pressed", up && state.press);
}
caption.classList.toggle("shown", up);
// The camera announces each move on the host.
if (up) {
host.addEventListener("moved", follow);
sized.observe(svg);
follow();
} else {
host.removeEventListener("moved", follow);
sized.disconnect();
}
if (up) place(caption, host, svg, blocks);
};
const set = (change: Partial<typeof state>) => {
Object.assign(state, change);
@@ -88,16 +78,14 @@ function still(svg: SVGSVGElement, owner: Map<SVGGElement, string>): SVGPolygonE
return hits;
}
/** Puts the caption one cell above the lifted top. */
function place(caption: HTMLElement, host: HTMLElement, svg: SVGSVGElement, faces: SVGPolygonElement[]) {
/** Puts the caption one step above the building's top. */
function place(caption: HTMLElement, host: HTMLElement, svg: SVGSVGElement, blocks: SVGGElement[]) {
const origin = host.getBoundingClientRect();
const ctm = svg.getScreenCTM();
if (!ctm) throw new Error("model is not rendered");
// Hit faces never lift, so they give the rest pose.
const rects = faces.map((f) => f.getBoundingClientRect());
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
const rects = blocks.map((b) => b.querySelector(".outline")!.getBoundingClientRect());
const left = Math.min(...rects.map((r) => r.left));
const right = Math.max(...rects.map((r) => r.right));
const top = Math.min(...rects.map((r) => r.top));
caption.style.left = `${(left + right) / 2 - origin.left}px`;
caption.style.top = `${top - origin.top - 2 * UNIT * ctm.d}px`;
caption.style.top = `${top - origin.top - UNIT * scale}px`;
}
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more