Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb1f0b2f1f | ||
|
|
564eb6739d | ||
|
|
b226581803 |
No files matched your search
@@ -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
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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/>.
|
||||
@@ -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 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +1,6 @@
|
||||
mod annotate;
|
||||
mod ask;
|
||||
mod attach;
|
||||
mod backdrop;
|
||||
mod content;
|
||||
mod document;
|
||||
mod editing;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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", ®ions.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)]);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, &[]));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", &[]);
|
||||
|
||||
@@ -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"))],
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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"))],
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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 个文件有改动"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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")]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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))),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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", "降序排序"),
|
||||
|
||||
@@ -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})"),
|
||||
|
||||
@@ -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", "演示检查"),
|
||||
|
||||
@@ -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}。",
|
||||
|
||||
@@ -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,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}"),
|
||||
];
|
||||
@@ -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", "打开文件"),
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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(
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 |
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user